Commit b3db610f by Douglas Hall Committed by Douglas Hall

ENT-770 Streamline checkout process for Enterprise orders.

This will skip any ecommerce-related pages for users who are attempting
to enroll in a course/program which has a 100% discount due to an
Enterprise offer and redirect the user to the appropriate LMS page.
parent 98ed9bc4
......@@ -34,6 +34,19 @@ def get_ecommerce_url(path=''):
return site_configuration.build_ecommerce_url(path)
def get_lms_courseware_url(course_run_id):
"""
Return the courseware URL for the given course run.
Arguments:
course_run_id (string): The serialized course run ID.
Returns:
string: The courseware URL.
"""
return get_lms_url('courses/{}/info'.format(course_run_id))
def get_lms_dashboard_url():
site_configuration = _get_site_configuration()
return site_configuration.student_dashboard_url
......
......@@ -6,6 +6,15 @@ from uuid import uuid4
import httpretty
import requests
from django.conf import settings
from waffle.models import Switch
from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.enterprise.constants import ENTERPRISE_OFFERS_SWITCH
from ecommerce.extensions.test.factories import (
EnterpriseCustomerConditionFactory,
EnterpriseOfferFactory,
EnterprisePercentageDiscountBenefitFactory
)
def raise_timeout(request, uri, headers): # pylint: disable=unused-argument
......@@ -26,6 +35,10 @@ class EnterpriseServiceMockMixin(object):
settings.ENTERPRISE_API_URL,
)
def setUp(self):
super(EnterpriseServiceMockMixin, self).setUp()
self.course_run = CourseFactory()
def mock_enterprise_customer_list_api_get(self):
"""
Helper function to register the enterprise customer API endpoint.
......@@ -528,3 +541,19 @@ class EnterpriseServiceMockMixin(object):
body=body,
content_type='application/json'
)
def prepare_enterprise_offer(self, percentage_discount_value=100):
Switch.objects.update_or_create(name=ENTERPRISE_OFFERS_SWITCH, defaults={'active': True})
benefit = EnterprisePercentageDiscountBenefitFactory(value=percentage_discount_value)
condition = EnterpriseCustomerConditionFactory()
EnterpriseOfferFactory(site=self.site, benefit=benefit, condition=condition)
self.mock_enterprise_learner_api(
learner_id=self.user.id,
enterprise_customer_uuid=str(condition.enterprise_customer_uuid),
course_run_id=self.course_run.id,
)
self.mock_catalog_contains_course_runs(
[self.course_run.id],
condition.enterprise_customer_uuid,
enterprise_customer_catalog_uuid=condition.enterprise_customer_catalog_uuid,
)
......@@ -18,6 +18,7 @@ from slumber.exceptions import SlumberHttpBaseException
from ecommerce.core.utils import traverse_pagination
from ecommerce.enterprise.exceptions import EnterpriseDoesNotExist
from ecommerce.extensions.offer.models import OFFER_PRIORITY_ENTERPRISE
ConditionalOffer = get_model('offer', 'ConditionalOffer')
StockRecord = get_model('partner', 'StockRecord')
......@@ -355,3 +356,19 @@ def set_enterprise_customer_cookie(site, response, enterprise_customer_uuid, max
)
return response
def has_enterprise_offer(basket):
"""
Return True if the basket has an Enterprise-related offer applied.
Arguments:
basket (Basket): The basket object.
Returns:
boolean: True if the basket has an Enterprise-related offer applied, false otherwise.
"""
for offer in basket.offer_discounts:
if offer['offer'].priority == OFFER_PRIORITY_ENTERPRISE:
return True
return False
import datetime
import hashlib
import urllib
from decimal import Decimal
import ddt
import httpretty
......@@ -28,6 +29,7 @@ from ecommerce.core.tests import toggle_switch
from ecommerce.core.url_utils import get_lms_url
from ecommerce.coupons.tests.mixins import CouponMixin, DiscoveryMockMixin
from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.entitlements.utils import create_or_update_course_entitlement
from ecommerce.extensions.analytics.utils import translate_basket_line_for_segment
from ecommerce.extensions.basket.utils import get_basket_switch_data
......@@ -367,7 +369,8 @@ class BasketMultipleItemsViewTests(DiscoveryTestMixin, DiscoveryMockMixin, LmsAp
@httpretty.activate
@ddt.ddt
class BasketSummaryViewTests(DiscoveryTestMixin, DiscoveryMockMixin, LmsApiMockMixin, ApiMockMixin, TestCase):
class BasketSummaryViewTests(EnterpriseServiceMockMixin, DiscoveryTestMixin, DiscoveryMockMixin, LmsApiMockMixin,
ApiMockMixin, TestCase):
""" BasketSummaryView basket view tests. """
path = reverse('basket:summary')
......@@ -747,6 +750,20 @@ class BasketSummaryViewTests(DiscoveryTestMixin, DiscoveryMockMixin, LmsApiMockM
'Could not apply the code \'THISISACOUPONCODE\'; it requires data sharing consent.'
)
@httpretty.activate
def test_free_basket_redirect(self):
"""
Verify redirect to FreeCheckoutView when basket is free
and an Enterprise-related offer is applied.
"""
self.course_run.create_or_update_seat('verified', True, Decimal(10), self.partner)
self.create_basket_and_add_product(self.course_run.seat_products[0])
self.prepare_enterprise_offer()
response = self.client.get(self.path)
self.assertRedirects(response, reverse('checkout:free-checkout'), fetch_redirect_response=False)
@httpretty.activate
class VoucherAddViewTests(LmsApiMockMixin, TestCase):
......
......@@ -2,12 +2,13 @@ from __future__ import unicode_literals
import logging
from datetime import datetime
from decimal import Decimal
from urllib import urlencode
import dateutil.parser
import waffle
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.shortcuts import redirect, render
from django.utils.translation import ugettext as _
from opaque_keys.edx.keys import CourseKey
from oscar.apps.basket.views import VoucherAddView as BaseVoucherAddView
......@@ -21,7 +22,7 @@ from ecommerce.core.exceptions import SiteConfigurationError
from ecommerce.core.url_utils import get_lms_url
from ecommerce.courses.utils import get_certificate_type_display_value, get_course_info_from_catalog
from ecommerce.enterprise.entitlements import get_enterprise_code_redemption_redirect
from ecommerce.enterprise.utils import CONSENT_FAILED_PARAM, get_enterprise_customer_from_voucher
from ecommerce.enterprise.utils import CONSENT_FAILED_PARAM, get_enterprise_customer_from_voucher, has_enterprise_offer
from ecommerce.extensions.analytics.utils import (
prepare_analytics_data,
track_segment_event,
......@@ -347,7 +348,10 @@ class BasketSummaryView(BasketView):
except Exception: # pylint: disable=broad-except
logger.exception('Failed to fire Cart Viewed event for basket [%d]', basket.id)
return super(BasketSummaryView, self).get(request, *args, **kwargs)
if has_enterprise_offer(basket) and basket.total_incl_tax == Decimal(0):
return redirect('checkout:free-checkout')
else:
return super(BasketSummaryView, self).get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(BasketSummaryView, self).get_context_data(**kwargs)
......
......@@ -8,7 +8,9 @@ from django.core.urlresolvers import reverse
from oscar.core.loading import get_model
from oscar.test import newfactories as factories
from ecommerce.core.url_utils import get_lms_courseware_url, get_lms_program_dashboard_url
from ecommerce.coupons.tests.mixins import DiscoveryMockMixin
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.extensions.checkout.exceptions import BasketNotFreeError
from ecommerce.extensions.checkout.utils import get_receipt_page_url
from ecommerce.extensions.checkout.views import ReceiptResponseView
......@@ -21,22 +23,31 @@ BasketAttributeType = get_model('basket', 'BasketAttributeType')
Order = get_model('order', 'Order')
class FreeCheckoutViewTests(TestCase):
class FreeCheckoutViewTests(EnterpriseServiceMockMixin, TestCase):
""" FreeCheckoutView view tests. """
path = reverse('checkout:free-checkout')
def setUp(self):
super(FreeCheckoutViewTests, self).setUp()
self.user = self.create_user()
self.bundle_attribute_value = 'test_bundle'
self.client.login(username=self.user.username, password=self.password)
def prepare_basket(self, price):
def prepare_basket(self, price, bundle=False):
""" Helper function that creates a basket and adds a product with set price to it. """
basket = factories.BasketFactory(owner=self.user, site=self.site)
basket.add_product(factories.ProductFactory(stockrecords__price_excl_tax=price), 1)
self.course_run.create_or_update_seat('verified', True, Decimal(price), self.partner)
basket.add_product(self.course_run.seat_products[0])
self.assertEqual(basket.lines.count(), 1)
self.assertEqual(basket.total_incl_tax, Decimal(price))
if bundle:
BasketAttribute.objects.update_or_create(
basket=basket,
attribute_type=BasketAttributeType.objects.get(name='bundle_identifier'),
value_text=self.bundle_attribute_value
)
def test_empty_basket(self):
""" Verify redirect to basket summary in case of empty basket. """
response = self.client.get(self.path)
......@@ -51,6 +62,30 @@ class FreeCheckoutViewTests(TestCase):
self.client.get(self.path)
@httpretty.activate
def test_enterprise_offer_program_redirect(self):
""" Verify redirect to the program dashboard page. """
self.prepare_basket(10, bundle=True)
self.prepare_enterprise_offer()
self.assertEqual(Order.objects.count(), 0)
response = self.client.get(self.path)
self.assertEqual(Order.objects.count(), 1)
expected_url = get_lms_program_dashboard_url(self.bundle_attribute_value)
self.assertRedirects(response, expected_url, fetch_redirect_response=False)
@httpretty.activate
def test_enterprise_offer_course_redirect(self):
""" Verify redirect to the courseware info page. """
self.prepare_basket(10)
self.prepare_enterprise_offer()
self.assertEqual(Order.objects.count(), 0)
response = self.client.get(self.path)
self.assertEqual(Order.objects.count(), 1)
expected_url = get_lms_courseware_url(self.course_run.id)
self.assertRedirects(response, expected_url, fetch_redirect_response=False)
@httpretty.activate
def test_successful_redirect(self):
""" Verify redirect to the receipt page. """
self.prepare_basket(0)
......
......@@ -12,7 +12,8 @@ from django.views.generic import RedirectView, TemplateView
from oscar.apps.checkout.views import * # pylint: disable=wildcard-import, unused-wildcard-import
from oscar.core.loading import get_class, get_model
from ecommerce.core.url_utils import get_lms_dashboard_url, get_lms_program_dashboard_url
from ecommerce.core.url_utils import get_lms_courseware_url, get_lms_dashboard_url, get_lms_program_dashboard_url
from ecommerce.enterprise.utils import has_enterprise_offer
from ecommerce.extensions.checkout.exceptions import BasketNotFreeError
from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin
from ecommerce.extensions.checkout.utils import get_receipt_page_url
......@@ -24,6 +25,24 @@ BasketAttributeType = get_model('basket', 'BasketAttributeType')
Order = get_model('order', 'Order')
def get_program_uuid(order):
"""
Return the program UUID associated with the given order, if one exists.
Arguments:
order (Order): The order object.
Returns:
string: The program UUID if the order is associated with a bundled purchase, otherwise None.
"""
bundle_attributes = BasketAttribute.objects.filter(
basket=order.basket,
attribute_type=BasketAttributeType.objects.get(name='bundle_identifier')
)
bundle_attribute = bundle_attributes.first()
return bundle_attribute.value_text if bundle_attribute else None
class FreeCheckoutView(EdxOrderPlacementMixin, RedirectView):
""" View to handle free checkouts.
......@@ -55,12 +74,22 @@ class FreeCheckoutView(EdxOrderPlacementMixin, RedirectView):
)
order = self.place_free_order(basket)
receipt_path = get_receipt_page_url(
order_number=order.number,
site_configuration=order.site.siteconfiguration
)
url = site.siteconfiguration.build_lms_url(receipt_path)
if has_enterprise_offer(basket):
# Skip the receipt page and redirect to the LMS
# if the order is free due to an Enterprise-related offer.
program_uuid = get_program_uuid(order)
if program_uuid:
url = get_lms_program_dashboard_url(program_uuid)
else:
course_run_id = order.lines.all()[:1].get().product.course.id
url = get_lms_courseware_url(course_run_id)
else:
receipt_path = get_receipt_page_url(
order_number=order.number,
site_configuration=order.site.siteconfiguration
)
url = site.siteconfiguration.build_lms_url(receipt_path)
else:
# If a user's basket is empty redirect the user to the basket summary
# page which displays the appropriate message for empty baskets.
......@@ -185,16 +214,8 @@ class ReceiptResponseView(ThankYouView):
return True
return False
def get_program_uuid(self, order):
bundle_attributes = BasketAttribute.objects.filter(
basket=order.basket,
attribute_type=BasketAttributeType.objects.get(name='bundle_identifier')
)
bundle_attribute = bundle_attributes.first()
return bundle_attribute.value_text if bundle_attribute else None
def get_order_dashboard_context(self, order):
program_uuid = self.get_program_uuid(order)
program_uuid = get_program_uuid(order)
if program_uuid:
order_dashboard_url = get_lms_program_dashboard_url(program_uuid)
else:
......
......@@ -9,7 +9,7 @@ from oscar.test.factories import * # pylint:disable=wildcard-import,unused-wild
from ecommerce.enterprise.benefits import EnterpriseAbsoluteDiscountBenefit, EnterprisePercentageDiscountBenefit
from ecommerce.enterprise.conditions import EnterpriseCustomerCondition
from ecommerce.extensions.offer.models import OFFER_PRIORITY_VOUCHER
from ecommerce.extensions.offer.models import OFFER_PRIORITY_ENTERPRISE, OFFER_PRIORITY_VOUCHER
from ecommerce.programs.benefits import AbsoluteDiscountBenefitWithoutRange, PercentageDiscountBenefitWithoutRange
from ecommerce.programs.conditions import ProgramCourseRunSeatsCondition
from ecommerce.programs.custom import class_path
......@@ -200,5 +200,5 @@ class EnterpriseOfferFactory(ConditionalOfferFactory):
condition = factory.SubFactory(EnterpriseCustomerConditionFactory)
max_basket_applications = 1
offer_type = ConditionalOffer.SITE
priority = 10
priority = OFFER_PRIORITY_ENTERPRISE
status = ConditionalOffer.OPEN
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