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):
payment_processor = get_default_processor_class()
try:
response_data = self._checkout(basket, payment_processor())
response_data = self._checkout(basket, payment_processor(), request)
except Exception as ex: # pylint: disable=broad-except
basket.delete()
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):
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.
If the contents of the basket are free, places an order immediately. Otherwise,
......@@ -229,7 +229,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
response_data = self._generate_basic_response(basket)
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
# 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 ecommerce.extensions.basket.models import BasketAttribute, BasketAttributeType
Basket = get_model('basket', 'basket')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
......@@ -17,13 +19,29 @@ class PaymentProcessorResponseInline(admin.TabularInline):
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)
class BasketAdminExtended(BasketAdmin):
raw_id_fields = ('vouchers', )
inlines = (LineInline, PaymentProcessorResponseInline,)
inlines = (LineInline, PaymentProcessorResponseInline, BasketAttributeInLine,)
show_full_result_count = False
@admin.register(Line)
class LineAdminExtended(LineAdmin):
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):
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
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):
# Call signal handler to notify listeners that something has been added to the basket
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
......
......@@ -56,4 +56,4 @@ class CatalogAdmin(admin.ModelAdmin):
class PartnerAdmin(admin.ModelAdmin):
# 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.
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):
class Partner(AbstractPartner):
# short_code is the unique identifier for the 'Partner'
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):
# 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
from django.dispatch import receiver
from oscar.core.loading import get_class
from oscar.core.loading import get_class, get_model
import waffle
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.courses.utils import mode_for_seat
from ecommerce.extensions.analytics.utils import silence_exceptions
logger = logging.getLogger(__name__)
post_checkout = get_class('checkout.signals', 'post_checkout')
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)
......@@ -28,10 +32,23 @@ def process_checkout_complete(sender, order=None, user=None, request=None, # py
if not waffle.switch_is_active('sailthru_enable'):
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
if request:
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
# 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
......@@ -41,24 +58,26 @@ def process_checkout_complete(sender, order=None, user=None, request=None, # py
# get product
product = line.product
# get price
price = line.line_price_excl_tax
# 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:
course_id = product.course_id
price = line.line_price_excl_tax
# figure out course url
course_url = _build_course_url(course_id)
course_id = product.course_id
# 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),
unit_cost=price, course_id=course_id, currency=order.currency,
site_code=order.site.siteconfiguration.partner.short_code,
message_id=message_id)
# pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously
update_course_enrollment.delay(order.user.email, _build_course_url(course_id),
False, mode_for_seat(product),
unit_cost=price, course_id=course_id, currency=order.currency,
site_code=partner.short_code,
message_id=message_id)
@receiver(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.
Arguments:
......@@ -68,28 +87,50 @@ def process_basket_addition(sender, product=None, user=None, request=None, **kwa
if not waffle.switch_is_active('sailthru_enable'):
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_url = _build_course_url(course_id)
course_id = product.course_id
# get price & currency
stock_record = product.stockrecords.first()
if stock_record:
price = stock_record.price_excl_tax
currency = stock_record.price_currency
stock_record = product.stockrecords.first()
if stock_record:
price = stock_record.price_excl_tax
currency = stock_record.price_currency
# return if no price, no need to add free items to shopping cart
if not price:
return
# return if no price, no need to add free items to shopping cart
if not price:
return
# pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously
update_course_enrollment.delay(user.email, course_url, True, mode_for_seat(product),
unit_cost=price, course_id=course_id, currency=currency,
site_code=request.site.siteconfiguration.partner.short_code,
message_id=request.COOKIES.get('sailthru_bid'))
# save Sailthru campaign ID, if there is one
message_id = request.COOKIES.get('sailthru_bid')
if message_id and basket:
BasketAttribute.objects.update_or_create(
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):
"""Build a course url from a course id and the host"""
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 @@
import logging
from mock import patch
from oscar.core.loading import get_model
from oscar.test.factories import create_order
from oscar.test.newfactories import UserFactory, BasketFactory
from django.test.client import RequestFactory
from ecommerce.tests.testcases import TestCase
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.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__)
TEST_EMAIL = "test@edx.org"
CAMPAIGN_COOKIE = "cookie_bid"
class SailthruTests(CourseCatalogTestMixin, TestCase):
class SailthruTests(CouponMixin, CourseCatalogTestMixin, TestCase):
"""
Tests for the Sailthru signals class.
"""
......@@ -26,7 +30,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
super(SailthruTests, self).setUp()
self.request_factory = RequestFactory()
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.user = UserFactory.create(username='test', email=TEST_EMAIL)
......@@ -50,6 +54,45 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
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_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):
"""
Test that the process_checkout signal handler properly calls the task routine
......@@ -64,7 +107,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
seat.attr.certificate_type,
course_id=self.course_id,
currency=order.currency,
message_id='cookie_bid',
message_id=CAMPAIGN_COOKIE,
site_code='edX',
unit_cost=order.total_excl_tax)
......@@ -104,7 +147,7 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
seat.attr.certificate_type,
course_id=self.course_id,
currency=order.currency,
message_id='cookie_bid',
message_id=CAMPAIGN_COOKIE,
site_code='edX',
unit_cost=order.total_excl_tax)
......@@ -120,6 +163,49 @@ class SailthruTests(CourseCatalogTestMixin, TestCase):
product=seat)
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):
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