Commit 7bf17ab3 by PaulWattenberger Committed by GitHub

Merge pull request #933 from edx/pwattenberger/ecom-5486

ECOM-5486 pass sailthru campaign id to Sailthru
parents 59c9346b 9958ce21
...@@ -187,7 +187,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView): ...@@ -187,7 +187,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
payment_processor = get_default_processor_class() payment_processor = get_default_processor_class()
try: try:
response_data = self._checkout(basket, payment_processor()) response_data = self._checkout(basket, payment_processor(), request)
except Exception as ex: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
basket.delete() basket.delete()
logger.exception('Failed to initiate checkout for Basket [%d]. The basket has been deleted.', basket_id) logger.exception('Failed to initiate checkout for Basket [%d]. The basket has been deleted.', basket_id)
...@@ -198,7 +198,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView): ...@@ -198,7 +198,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
return Response(response_data, status=status.HTTP_200_OK) return Response(response_data, status=status.HTTP_200_OK)
def _checkout(self, basket, payment_processor): def _checkout(self, basket, payment_processor, request=None):
"""Perform checkout operations for the given basket. """Perform checkout operations for the given basket.
If the contents of the basket are free, places an order immediately. Otherwise, If the contents of the basket are free, places an order immediately. Otherwise,
...@@ -229,7 +229,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView): ...@@ -229,7 +229,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
response_data = self._generate_basic_response(basket) response_data = self._generate_basic_response(basket)
if basket.total_excl_tax == 0: if basket.total_excl_tax == 0:
order = self.place_free_order(basket) order = self.place_free_order(basket, request)
# Note: Our order serializer could be used here, but in an effort to pare down the information # Note: Our order serializer could be used here, but in an effort to pare down the information
# returned by this endpoint, simply returning the order number will suffice for now. # returned by this endpoint, simply returning the order number will suffice for now.
......
from oscar.apps.basket.admin import * # noqa pylint: disable=wildcard-import,unused-wildcard-import from oscar.apps.basket.admin import * # noqa pylint: disable=wildcard-import,unused-wildcard-import
from ecommerce.extensions.basket.models import BasketAttribute, BasketAttributeType
Basket = get_model('basket', 'basket') Basket = get_model('basket', 'basket')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
...@@ -17,13 +19,29 @@ class PaymentProcessorResponseInline(admin.TabularInline): ...@@ -17,13 +19,29 @@ class PaymentProcessorResponseInline(admin.TabularInline):
return False return False
class BasketAttributeInLine(admin.TabularInline):
model = BasketAttribute
readonly_fields = ('id', 'attribute_type', 'value_text',)
extra = 0
def has_add_permission(self, request):
# Users are not allowed to add BasketAttribute objects
return False
@admin.register(Basket) @admin.register(Basket)
class BasketAdminExtended(BasketAdmin): class BasketAdminExtended(BasketAdmin):
raw_id_fields = ('vouchers', ) raw_id_fields = ('vouchers', )
inlines = (LineInline, PaymentProcessorResponseInline,) inlines = (LineInline, PaymentProcessorResponseInline, BasketAttributeInLine,)
show_full_result_count = False show_full_result_count = False
@admin.register(Line) @admin.register(Line)
class LineAdminExtended(LineAdmin): class LineAdminExtended(LineAdmin):
show_full_result_count = False show_full_result_count = False
@admin.register(BasketAttributeType)
class BasketAttributeTypeAdmin(admin.ModelAdmin):
list_display = ('id', 'name',)
readonly_fields = ('id', 'name',)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basket', '0006_basket_site'),
]
operations = [
migrations.CreateModel(
name='BasketAttribute',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('value_text', models.TextField(verbose_name='Text Attribute')),
],
),
migrations.CreateModel(
name='BasketAttributeType',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(unique=True, max_length=128, verbose_name='Name')),
],
),
migrations.AddField(
model_name='basketattribute',
name='attribute_type',
field=models.ForeignKey(verbose_name='Attribute Type', to='basket.BasketAttributeType'),
),
migrations.AddField(
model_name='basketattribute',
name='basket',
field=models.ForeignKey(verbose_name='Basket', to='basket.Basket'),
),
migrations.AlterUniqueTogether(
name='basketattribute',
unique_together=set([('basket', 'attribute_type')]),
),
]
...@@ -58,5 +58,29 @@ class Basket(AbstractBasket): ...@@ -58,5 +58,29 @@ class Basket(AbstractBasket):
num_lines=self.num_lines) num_lines=self.num_lines)
class BasketAttributeType(models.Model):
"""
Used to keep attribute types for BasketAttribute
"""
name = models.CharField(_("Name"), max_length=128, unique=True)
def __unicode__(self):
return self.name
class BasketAttribute(models.Model):
"""
Used to add fields to basket without modifying basket directly. Fields
can be added by defining new types. Currently only supports text fields,
but could be extended
"""
basket = models.ForeignKey('basket.Basket', verbose_name=_("Basket"))
attribute_type = models.ForeignKey('basket.BasketAttributeType', verbose_name=_("Attribute Type"))
value_text = models.TextField(_("Text Attribute"))
class Meta(object):
unique_together = ('basket', 'attribute_type')
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from oscar.apps.basket.models import * # noqa pylint: disable=wildcard-import,unused-wildcard-import,wrong-import-position from oscar.apps.basket.models import * # noqa pylint: disable=wildcard-import,unused-wildcard-import,wrong-import-position
...@@ -53,7 +53,7 @@ def prepare_basket(request, product, voucher=None): ...@@ -53,7 +53,7 @@ def prepare_basket(request, product, voucher=None):
# Call signal handler to notify listeners that something has been added to the basket # Call signal handler to notify listeners that something has been added to the basket
basket_addition = get_class('basket.signals', 'basket_addition') basket_addition = get_class('basket.signals', 'basket_addition')
basket_addition.send(sender=basket_addition, product=product, user=request.user, request=request) basket_addition.send(sender=basket_addition, product=product, user=request.user, request=request, basket=basket)
return basket return basket
......
...@@ -56,4 +56,4 @@ class CatalogAdmin(admin.ModelAdmin): ...@@ -56,4 +56,4 @@ class CatalogAdmin(admin.ModelAdmin):
class PartnerAdmin(admin.ModelAdmin): class PartnerAdmin(admin.ModelAdmin):
# NOTE: Do not include the users field. The users table will grow so large # NOTE: Do not include the users field. The users table will grow so large
# as to make the page timeout. Additionally, we don't actually make use of the field. # as to make the page timeout. Additionally, we don't actually make use of the field.
fields = ('name', 'short_code',) fields = ('name', 'short_code', 'enable_sailthru')
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('partner', '0008_auto_20150914_1057'),
]
operations = [
migrations.AddField(
model_name='partner',
name='enable_sailthru',
field=models.BooleanField(default=True, help_text='Report purchases/enrolls to Sailthru.', verbose_name='Enable Sailthru Reporting'),
),
]
...@@ -12,6 +12,9 @@ class StockRecord(AbstractStockRecord): ...@@ -12,6 +12,9 @@ class StockRecord(AbstractStockRecord):
class Partner(AbstractPartner): class Partner(AbstractPartner):
# short_code is the unique identifier for the 'Partner' # short_code is the unique identifier for the 'Partner'
short_code = models.CharField(max_length=8, unique=True, null=False, blank=False) short_code = models.CharField(max_length=8, unique=True, null=False, blank=False)
enable_sailthru = models.BooleanField(default=True, verbose_name=_('Enable Sailthru Reporting'),
help_text=_(
'Determines if purchases/enrolls should be reported to Sailthru.'))
class Meta(object): class Meta(object):
# Model name that will appear in the admin panel # Model name that will appear in the admin panel
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
from ecommerce.sailthru.signals import SAILTHRU_CAMPAIGN
def create_attribute(apps, schema_editor):
BasketAttributeType = apps.get_model('basket', 'BasketAttributeType')
BasketAttributeType.objects.create(name=SAILTHRU_CAMPAIGN)
def delete_attribute(apps, schema_editor):
BasketAttributeType = apps.get_model('basket', 'BasketAttributeType')
BasketAttributeType.objects.get(name=SAILTHRU_CAMPAIGN).delete()
class Migration(migrations.Migration):
dependencies = [
('sailthru', '0001_initial'),
('basket', '0007_auto_20160907_2040'),
]
operations = [
migrations.RunPython(create_attribute, delete_attribute)
]
\ No newline at end of file
import logging import logging
from django.dispatch import receiver from django.dispatch import receiver
from oscar.core.loading import get_class from oscar.core.loading import get_class, get_model
import waffle import waffle
from ecommerce_worker.sailthru.v1.tasks import update_course_enrollment from ecommerce_worker.sailthru.v1.tasks import update_course_enrollment
from ecommerce.extensions.analytics.utils import silence_exceptions from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME
from ecommerce.core.url_utils import get_lms_url from ecommerce.core.url_utils import get_lms_url
from ecommerce.courses.utils import mode_for_seat from ecommerce.courses.utils import mode_for_seat
from ecommerce.extensions.analytics.utils import silence_exceptions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
post_checkout = get_class('checkout.signals', 'post_checkout') post_checkout = get_class('checkout.signals', 'post_checkout')
basket_addition = get_class('basket.signals', 'basket_addition') basket_addition = get_class('basket.signals', 'basket_addition')
BasketAttribute = get_model('basket', 'BasketAttribute')
BasketAttributeType = get_model('basket', 'BasketAttributeType')
SAILTHRU_CAMPAIGN = 'sailthru_bid'
@receiver(post_checkout) @receiver(post_checkout)
...@@ -28,10 +32,23 @@ def process_checkout_complete(sender, order=None, user=None, request=None, # py ...@@ -28,10 +32,23 @@ def process_checkout_complete(sender, order=None, user=None, request=None, # py
if not waffle.switch_is_active('sailthru_enable'): if not waffle.switch_is_active('sailthru_enable'):
return return
partner = order.site.siteconfiguration.partner
if not partner.enable_sailthru:
return
# get campaign id from cookies, or saved value in basket
message_id = None message_id = None
if request: if request:
message_id = request.COOKIES.get('sailthru_bid') message_id = request.COOKIES.get('sailthru_bid')
if not message_id:
saved_id = BasketAttribute.objects.filter(
basket=order.basket,
attribute_type=_get_attribute_type()
)
if len(saved_id) > 0:
message_id = saved_id[0].value_text
# loop through lines in order # loop through lines in order
# If multi product orders become common it may be worthwhile to pass an array of # If multi product orders become common it may be worthwhile to pass an array of
# orders to the worker in one call to save overhead, however, that would be difficult # orders to the worker in one call to save overhead, however, that would be difficult
...@@ -41,24 +58,26 @@ def process_checkout_complete(sender, order=None, user=None, request=None, # py ...@@ -41,24 +58,26 @@ def process_checkout_complete(sender, order=None, user=None, request=None, # py
# get product # get product
product = line.product product = line.product
# get price # ignore everything except course seats. no support for coupons as of yet
price = line.line_price_excl_tax product_class_name = product.get_product_class().name
if product_class_name == SEAT_PRODUCT_CLASS_NAME:
course_id = product.course_id price = line.line_price_excl_tax
# figure out course url course_id = product.course_id
course_url = _build_course_url(course_id)
# pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously # pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously
update_course_enrollment.delay(order.user.email, course_url, False, mode_for_seat(product), update_course_enrollment.delay(order.user.email, _build_course_url(course_id),
unit_cost=price, course_id=course_id, currency=order.currency, False, mode_for_seat(product),
site_code=order.site.siteconfiguration.partner.short_code, unit_cost=price, course_id=course_id, currency=order.currency,
message_id=message_id) site_code=partner.short_code,
message_id=message_id)
@receiver(basket_addition) @receiver(basket_addition)
@silence_exceptions("Failed to call Sailthru upon basket addition.") @silence_exceptions("Failed to call Sailthru upon basket addition.")
def process_basket_addition(sender, product=None, user=None, request=None, **kwargs): # pylint: disable=unused-argument def process_basket_addition(sender, product=None, user=None, request=None, basket=None,
**kwargs): # pylint: disable=unused-argument
"""Tell Sailthru when payment started. """Tell Sailthru when payment started.
Arguments: Arguments:
...@@ -68,28 +87,50 @@ def process_basket_addition(sender, product=None, user=None, request=None, **kwa ...@@ -68,28 +87,50 @@ def process_basket_addition(sender, product=None, user=None, request=None, **kwa
if not waffle.switch_is_active('sailthru_enable'): if not waffle.switch_is_active('sailthru_enable'):
return return
course_id = product.course_id partner = request.site.siteconfiguration.partner
if not partner.enable_sailthru:
return
# ignore everything except course seats. no support for coupons as of yet
product_class_name = product.get_product_class().name
if product_class_name == SEAT_PRODUCT_CLASS_NAME:
# figure out course url course_id = product.course_id
course_url = _build_course_url(course_id)
# get price & currency stock_record = product.stockrecords.first()
stock_record = product.stockrecords.first() if stock_record:
if stock_record: price = stock_record.price_excl_tax
price = stock_record.price_excl_tax currency = stock_record.price_currency
currency = stock_record.price_currency
# return if no price, no need to add free items to shopping cart # return if no price, no need to add free items to shopping cart
if not price: if not price:
return return
# pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously # save Sailthru campaign ID, if there is one
update_course_enrollment.delay(user.email, course_url, True, mode_for_seat(product), message_id = request.COOKIES.get('sailthru_bid')
unit_cost=price, course_id=course_id, currency=currency, if message_id and basket:
site_code=request.site.siteconfiguration.partner.short_code, BasketAttribute.objects.update_or_create(
message_id=request.COOKIES.get('sailthru_bid')) basket=basket,
attribute_type=_get_attribute_type(),
value_text=message_id
)
# pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously
update_course_enrollment.delay(user.email, _build_course_url(course_id), True, mode_for_seat(product),
unit_cost=price, course_id=course_id, currency=currency,
site_code=partner.short_code,
message_id=message_id)
def _build_course_url(course_id): def _build_course_url(course_id):
"""Build a course url from a course id and the host""" """Build a course url from a course id and the host"""
return get_lms_url('courses/{}/info'.format(course_id)) return get_lms_url('courses/{}/info'.format(course_id))
def _get_attribute_type():
""" Read attribute type for Sailthru campaign id"""
try:
attribute_type = BasketAttributeType.objects.get(name=SAILTHRU_CAMPAIGN)
except BasketAttributeType.DoesNotExist:
attribute_type = BasketAttributeType.objects.create(name=SAILTHRU_CAMPAIGN)
return attribute_type
...@@ -2,22 +2,26 @@ ...@@ -2,22 +2,26 @@
import logging import logging
from mock import patch from mock import patch
from oscar.core.loading import get_model
from oscar.test.factories import create_order from oscar.test.factories import create_order
from oscar.test.newfactories import UserFactory, BasketFactory from oscar.test.newfactories import UserFactory, BasketFactory
from django.test.client import RequestFactory from django.test.client import RequestFactory
from ecommerce.tests.testcases import TestCase
from ecommerce.core.tests import toggle_switch from ecommerce.core.tests import toggle_switch
from ecommerce.sailthru.signals import process_checkout_complete, process_basket_addition from ecommerce.coupons.tests.mixins import CouponMixin
from ecommerce.courses.models import Course from ecommerce.courses.models import Course
from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin
from ecommerce.sailthru.signals import process_checkout_complete, process_basket_addition, SAILTHRU_CAMPAIGN
from ecommerce.tests.factories import SiteConfigurationFactory
from ecommerce.tests.testcases import TestCase
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
TEST_EMAIL = "test@edx.org" TEST_EMAIL = "test@edx.org"
CAMPAIGN_COOKIE = "cookie_bid"
class SailthruTests(CourseCatalogTestMixin, TestCase): class SailthruTests(CouponMixin, CourseCatalogTestMixin, TestCase):
""" """
Tests for the Sailthru signals class. Tests for the Sailthru signals class.
""" """
...@@ -26,7 +30,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase): ...@@ -26,7 +30,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
super(SailthruTests, self).setUp() super(SailthruTests, self).setUp()
self.request_factory = RequestFactory() self.request_factory = RequestFactory()
self.request = self.request_factory.get("foo") self.request = self.request_factory.get("foo")
self.request.COOKIES['sailthru_bid'] = 'cookie_bid' self.request.COOKIES['sailthru_bid'] = CAMPAIGN_COOKIE
self.request.site = self.site self.request.site = self.site
self.user = UserFactory.create(username='test', email=TEST_EMAIL) self.user = UserFactory.create(username='test', email=TEST_EMAIL)
...@@ -50,6 +54,45 @@ class SailthruTests(CourseCatalogTestMixin, TestCase): ...@@ -50,6 +54,45 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
self.assertFalse(mock_log_error.called) self.assertFalse(mock_log_error.called)
@patch('ecommerce_worker.sailthru.v1.tasks.update_course_enrollment.delay') @patch('ecommerce_worker.sailthru.v1.tasks.update_course_enrollment.delay')
@patch('ecommerce.sailthru.signals.logger.error')
def test_just_return_if_partner_not_supported(self, mock_log_error, mock_update_course_enrollment):
"""
Ensure that calls just return if enable_sailthru turned off for partner
"""
site_configuration = SiteConfigurationFactory(partner__name='TestX')
site_configuration.partner.enable_sailthru = False
self.request.site.siteconfiguration = site_configuration
process_basket_addition(None, request=self.request)
self.assertFalse(mock_update_course_enrollment.called)
self.assertFalse(mock_log_error.called)
__, order = self._create_order(99)
order.site.siteconfiguration = site_configuration
process_checkout_complete(None, order=order)
self.assertFalse(mock_update_course_enrollment.called)
self.assertFalse(mock_log_error.called)
@patch('ecommerce_worker.sailthru.v1.tasks.update_course_enrollment.delay')
@patch('ecommerce.sailthru.signals.logger.error')
def test_just_return_not_course(self, mock_log_error, mock_update_course_enrollment):
"""
Verify data for coupon-related orders is not sent to Sailthru.
"""
coupon = self.create_coupon()
basket = BasketFactory()
basket.add_product(coupon, 1)
process_basket_addition(None, request=self.request,
user=self.user,
product=coupon, basket=basket)
self.assertFalse(mock_update_course_enrollment.called)
self.assertFalse(mock_log_error.called)
order = create_order(number=1, basket=basket, user=self.user)
process_checkout_complete(None, order=order, request=None)
self.assertFalse(mock_update_course_enrollment.called)
self.assertFalse(mock_log_error.called)
@patch('ecommerce_worker.sailthru.v1.tasks.update_course_enrollment.delay')
def test_process_checkout_complete(self, mock_update_course_enrollment): def test_process_checkout_complete(self, mock_update_course_enrollment):
""" """
Test that the process_checkout signal handler properly calls the task routine Test that the process_checkout signal handler properly calls the task routine
...@@ -64,7 +107,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase): ...@@ -64,7 +107,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
seat.attr.certificate_type, seat.attr.certificate_type,
course_id=self.course_id, course_id=self.course_id,
currency=order.currency, currency=order.currency,
message_id='cookie_bid', message_id=CAMPAIGN_COOKIE,
site_code='edX', site_code='edX',
unit_cost=order.total_excl_tax) unit_cost=order.total_excl_tax)
...@@ -104,7 +147,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase): ...@@ -104,7 +147,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
seat.attr.certificate_type, seat.attr.certificate_type,
course_id=self.course_id, course_id=self.course_id,
currency=order.currency, currency=order.currency,
message_id='cookie_bid', message_id=CAMPAIGN_COOKIE,
site_code='edX', site_code='edX',
unit_cost=order.total_excl_tax) unit_cost=order.total_excl_tax)
...@@ -120,6 +163,49 @@ class SailthruTests(CourseCatalogTestMixin, TestCase): ...@@ -120,6 +163,49 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
product=seat) product=seat)
self.assertFalse(mock_update_course_enrollment.called) self.assertFalse(mock_update_course_enrollment.called)
@patch('ecommerce_worker.sailthru.v1.tasks.update_course_enrollment.delay')
def test_save_campaign_id(self, mock_update_course_enrollment):
"""
Verify the Sailthru campaign ID is saved as a basket attribute.
"""
# force exception in _get_attribute_type for coverage
BasketAttributeType = get_model('basket', 'BasketAttributeType')
try:
basket_attribute = BasketAttributeType.objects.get(name=SAILTHRU_CAMPAIGN)
self.assertEqual(unicode(basket_attribute), SAILTHRU_CAMPAIGN)
basket_attribute.delete()
except BasketAttributeType.DoesNotExist:
pass
seat, order = self._create_order(99)
process_basket_addition(None, request=self.request,
user=self.user,
product=seat, basket=order.basket)
self.assertTrue(mock_update_course_enrollment.called)
mock_update_course_enrollment.assert_called_with(TEST_EMAIL,
self.course_url,
True,
seat.attr.certificate_type,
course_id=self.course_id,
currency=order.currency,
message_id=CAMPAIGN_COOKIE,
site_code='edX',
unit_cost=order.total_excl_tax)
# now call checkout_complete with the same basket to see if campaign id saved and restored
process_checkout_complete(None, order=order, request=None)
self.assertTrue(mock_update_course_enrollment.called)
mock_update_course_enrollment.assert_called_with(TEST_EMAIL,
self.course_url,
False,
seat.attr.certificate_type,
course_id=self.course_id,
currency=order.currency,
message_id=CAMPAIGN_COOKIE,
site_code='edX',
unit_cost=order.total_excl_tax)
def _create_order(self, price): def _create_order(self, price):
seat = self.course.create_or_update_seat('verified', False, price, self.partner, None) seat = self.course.create_or_update_seat('verified', False, price, self.partner, None)
......
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