Commit 5c92c456 by zubair-arbi

Merge branch 'zub/ENT-1052-apply-enterprise-offer-by-catalog-id' into business/fontina

parents d2efc15e 64d8a379
1. Record Architecture Decisions
--------------------------------
Status
------
Accepted
Context
-------
We would like to keep a historical record on the architectural
decisions we make with this app as it evolves over time.
Decision
--------
We will use Architecture Decision Records, as described by
Michael Nygard in `Documenting Architecture Decisions`_
.. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions
Consequences
------------
See Michael Nygard's article, linked above.
References
----------
* https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf
* https://github.com/npryce/adr-tools/tree/master/doc/adr
2. No synchronous server-to-server calls within transactions
------------------------------------------------------------
Status
------
Accepted
Context
-------
The ecommerce service currently makes various synchronous server-to-server calls within transactions. This has led to
both instability of the overall service and performance issues. The stability issues come in part related to the default
atomic transactions and resulting DB Lock Wait Timeout errors that occur.
Decision
--------
We will add no new synchronous server-to-server calls. Unless special permission is granted by `the Elder`_, these issues must be resolved in some other way.
.. _the Elder: https://openedx.atlassian.net/wiki/spaces/ENG/pages/761200984/Ecommerce+Guild+Homepage
Consequences
------------
Additionally, there will need to be an ongoing effort to transition existing synchronous server-to-server calls to the appropriate future design for each case.
Some possible alternative solutions include:
* Moving calls to be asynchronously made from the ecommerce workers
* Passing more required data to an endpoint
* Making multiple calls from the front-end
* Moving data that should belong to ecommerce
* Data redundancy
* Caching data in the JWT
* Other
References
----------
* https://www.reactivemanifesto.org/
...@@ -12,7 +12,7 @@ from factory.fuzzy import FuzzyText ...@@ -12,7 +12,7 @@ from factory.fuzzy import FuzzyText
from oscar.core.loading import get_class, get_model from oscar.core.loading import get_class, get_model
from oscar.test.factories import OrderFactory, OrderLineFactory, RangeFactory, VoucherFactory from oscar.test.factories import OrderFactory, OrderLineFactory, RangeFactory, VoucherFactory
from ecommerce.core.url_utils import get_lms_url from ecommerce.core.url_utils import get_lms_dashboard_url, get_lms_url
from ecommerce.coupons.tests.mixins import CouponMixin, DiscoveryMockMixin from ecommerce.coupons.tests.mixins import CouponMixin, DiscoveryMockMixin
from ecommerce.coupons.views import voucher_is_valid from ecommerce.coupons.views import voucher_is_valid
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
...@@ -430,12 +430,15 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En ...@@ -430,12 +430,15 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En
@httpretty.activate @httpretty.activate
def test_basket_redirect_enrollment_code(self): def test_basket_redirect_enrollment_code(self):
""" Verify the view redirects to the receipt page when an enrollment code is provided. """ """ Verify the view redirects to the LMS dashboard when an enrollment code is provided. """
code = self.create_coupon_and_get_code(benefit_value=100, code='', catalog=self.catalog) code = self.create_coupon_and_get_code(benefit_value=100, code='', catalog=self.catalog)
self.mock_account_api(self.request, self.user.username, data={'is_active': True}) self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response() self.mock_access_token_response()
self.assert_redirects_to_receipt_page(code=code) self.assert_redemption_page_redirects(
get_lms_dashboard_url(),
code=code,
)
@httpretty.activate @httpretty.activate
@mock.patch.object(EdxOrderPlacementMixin, 'place_free_order') @mock.patch.object(EdxOrderPlacementMixin, 'place_free_order')
...@@ -444,7 +447,7 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En ...@@ -444,7 +447,7 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En
code = self.create_coupon_and_get_code(benefit_value=100, code='', catalog=self.catalog) code = self.create_coupon_and_get_code(benefit_value=100, code='', catalog=self.catalog)
self.mock_account_api(self.request, self.user.username, data={'is_active': True}) self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response() self.mock_access_token_response()
place_free_order.return_value = Exception place_free_order.side_effect = Exception
with mock.patch('ecommerce.coupons.views.logger.exception') as mock_logger: with mock.patch('ecommerce.coupons.views.logger.exception') as mock_logger:
self.assert_redemption_page_redirects( self.assert_redemption_page_redirects(
...@@ -528,7 +531,7 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En ...@@ -528,7 +531,7 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En
@httpretty.activate @httpretty.activate
def test_enterprise_customer_successful_redemption(self): def test_enterprise_customer_successful_redemption(self):
""" Verify the view redirects to LMS when valid consent is provided. """ """ Verify the view redirects to the LMS dashboard when valid consent is provided. """
code = self.prepare_enterprise_data(catalog=self.catalog) code = self.prepare_enterprise_data(catalog=self.catalog)
self.mock_enterprise_learner_api_for_learner_with_no_enterprise() self.mock_enterprise_learner_api_for_learner_with_no_enterprise()
self.mock_enterprise_learner_post_api() self.mock_enterprise_learner_post_api()
...@@ -539,9 +542,10 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En ...@@ -539,9 +542,10 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En
ENTERPRISE_CUSTOMER ENTERPRISE_CUSTOMER
) )
self.assert_redirects_to_receipt_page( self.assert_redemption_page_redirects(
get_lms_dashboard_url(),
code=code, code=code,
consent_token=consent_token consent_token=consent_token,
) )
last_request = httpretty.last_request() last_request = httpretty.last_request()
self.assertEqual(last_request.path, '/api/enrollment/v1/enrollment') self.assertEqual(last_request.path, '/api/enrollment/v1/enrollment')
...@@ -595,7 +599,7 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En ...@@ -595,7 +599,7 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En
@httpretty.activate @httpretty.activate
def test_multiple_vouchers(self): def test_multiple_vouchers(self):
""" Verify a redirect to LMS happens when a basket with already existing vouchers is used. """ """ Verify a redirect to LMS dashboard happens when a basket with already existing vouchers is used. """
code = self.create_coupon_and_get_code(benefit_value=100, code='', catalog=self.catalog) code = self.create_coupon_and_get_code(benefit_value=100, code='', catalog=self.catalog)
basket = Basket.get_basket(self.user, self.site) basket = Basket.get_basket(self.user, self.site)
basket.vouchers.add(Voucher.objects.get(code=code)) basket.vouchers.add(Voucher.objects.get(code=code))
...@@ -603,7 +607,10 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En ...@@ -603,7 +607,10 @@ class CouponRedeemViewTests(CouponMixin, DiscoveryTestMixin, LmsApiMockMixin, En
self.mock_account_api(self.request, self.user.username, data={'is_active': True}) self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response() self.mock_access_token_response()
self.assert_redirects_to_receipt_page(code=code) self.assert_redemption_page_redirects(
get_lms_dashboard_url(),
code=code,
)
@httpretty.activate @httpretty.activate
def test_already_enrolled_rejection(self): def test_already_enrolled_rejection(self):
......
...@@ -16,7 +16,7 @@ from django.utils.translation import ugettext as _ ...@@ -16,7 +16,7 @@ from django.utils.translation import ugettext as _
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from oscar.core.loading import get_class, get_model from oscar.core.loading import get_class, get_model
from ecommerce.core.url_utils import get_ecommerce_url from ecommerce.core.url_utils import get_ecommerce_url, get_lms_dashboard_url
from ecommerce.core.views import StaffOnlyMixin from ecommerce.core.views import StaffOnlyMixin
from ecommerce.coupons.decorators import login_required_for_credit from ecommerce.coupons.decorators import login_required_for_credit
from ecommerce.coupons.utils import is_voucher_applied from ecommerce.coupons.utils import is_voucher_applied
...@@ -32,7 +32,6 @@ from ecommerce.enterprise.utils import ( ...@@ -32,7 +32,6 @@ from ecommerce.enterprise.utils import (
from ecommerce.extensions.api import exceptions from ecommerce.extensions.api import exceptions
from ecommerce.extensions.basket.utils import prepare_basket from ecommerce.extensions.basket.utils import prepare_basket
from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin
from ecommerce.extensions.checkout.utils import get_receipt_page_url
from ecommerce.extensions.offer.utils import render_email_confirmation_if_required from ecommerce.extensions.offer.utils import render_email_confirmation_if_required
from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException
from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_code from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_code
...@@ -154,7 +153,6 @@ class CouponRedeemView(EdxOrderPlacementMixin, View): ...@@ -154,7 +153,6 @@ class CouponRedeemView(EdxOrderPlacementMixin, View):
code = request.GET.get('code') code = request.GET.get('code')
sku = request.GET.get('sku') sku = request.GET.get('sku')
failure_url = request.GET.get('failure_url') failure_url = request.GET.get('failure_url')
site_configuration = request.site.siteconfiguration
if not code: if not code:
return render(request, template_name, {'error': _('Code not provided.')}) return render(request, template_name, {'error': _('Code not provided.')})
...@@ -238,12 +236,7 @@ class CouponRedeemView(EdxOrderPlacementMixin, View): ...@@ -238,12 +236,7 @@ class CouponRedeemView(EdxOrderPlacementMixin, View):
return render(request, template_name, {'error': msg}) return render(request, template_name, {'error': msg})
if basket.total_excl_tax == 0: if basket.total_excl_tax == 0:
try: return self._handle_free_order(basket)
order = self.place_free_order(basket)
return HttpResponseRedirect(get_receipt_page_url(site_configuration, order.number))
except: # pylint: disable=bare-except
logger.exception('Failed to create a free order for basket [%d]', basket.id)
return HttpResponseRedirect(reverse('checkout:error'))
if enterprise_customer: if enterprise_customer:
if is_voucher_applied(basket, voucher): if is_voucher_applied(basket, voucher):
...@@ -260,6 +253,14 @@ class CouponRedeemView(EdxOrderPlacementMixin, View): ...@@ -260,6 +253,14 @@ class CouponRedeemView(EdxOrderPlacementMixin, View):
return HttpResponseRedirect(reverse('basket:summary')) return HttpResponseRedirect(reverse('basket:summary'))
def _handle_free_order(self, basket):
try:
self.place_free_order(basket)
return HttpResponseRedirect(get_lms_dashboard_url())
except: # pylint: disable=bare-except
logger.exception('Failed to create a free order for basket [%d]', basket.id)
return HttpResponseRedirect(reverse('checkout:error'))
class EnrollmentCodeCsvView(View): class EnrollmentCodeCsvView(View):
""" Download enrollment code CSV file view. """ """ Download enrollment code CSV file view. """
......
import datetime import datetime
import json import json
from uuid import uuid4
import ddt import ddt
import httpretty import httpretty
...@@ -16,6 +17,7 @@ from ecommerce.courses.tests.factories import CourseFactory ...@@ -16,6 +17,7 @@ from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.entitlements.utils import create_or_update_course_entitlement from ecommerce.entitlements.utils import create_or_update_course_entitlement
from ecommerce.extensions.basket.tests.mixins import BasketMixin from ecommerce.extensions.basket.tests.mixins import BasketMixin
from ecommerce.extensions.basket.utils import ( from ecommerce.extensions.basket.utils import (
ENTERPRISE_CATALOG_ATTRIBUTE_TYPE,
add_utm_params_to_url, add_utm_params_to_url,
attribute_cookie_data, attribute_cookie_data,
get_basket_switch_data, get_basket_switch_data,
...@@ -417,6 +419,37 @@ class BasketUtilsTests(DiscoveryTestMixin, BasketMixin, TestCase): ...@@ -417,6 +419,37 @@ class BasketUtilsTests(DiscoveryTestMixin, BasketMixin, TestCase):
# Verify that no exception is raised when no basket attribute exists fitting the delete statement parameters # Verify that no exception is raised when no basket attribute exists fitting the delete statement parameters
prepare_basket(request, [product]) prepare_basket(request, [product])
def test_prepare_basket_with_enterprise_catalog(self):
"""
Test `prepare_basket` with enterprise catalog.
"""
product = ProductFactory()
request = self.request
expected_enterprise_catalog_uuid = str(uuid4())
request.GET = {'catalog': expected_enterprise_catalog_uuid}
basket = prepare_basket(request, [product])
# Verify that the enterprise catalog attribute exists for the basket
# when basket is prepared with the value of provide catalog UUID
enterprise_catalog_uuid = BasketAttribute.objects.get(
basket=basket,
attribute_type__name=ENTERPRISE_CATALOG_ATTRIBUTE_TYPE
).value_text
assert expected_enterprise_catalog_uuid == enterprise_catalog_uuid
# Now verify that `prepare_basket` method removes the enterprise
# catalog attribute if there is not catalog in url
request.GET = {}
basket = prepare_basket(request, [product])
# Verify that the enterprise catalog attribute exists for the basket
# when basket is prepared with the value of provide catalog UUID
with self.assertRaises(BasketAttribute.DoesNotExist):
BasketAttribute.objects.get(
basket=basket,
attribute_type__name=ENTERPRISE_CATALOG_ATTRIBUTE_TYPE
)
def test_basket_switch_data(self): def test_basket_switch_data(self):
"""Verify the correct basket switch data (single vs. multi quantity) is retrieved.""" """Verify the correct basket switch data (single vs. multi quantity) is retrieved."""
__, seat, enrollment_code = self.prepare_course_seat_and_enrollment_code() __, seat, enrollment_code = self.prepare_course_seat_and_enrollment_code()
......
...@@ -23,6 +23,7 @@ BasketAttribute = get_model('basket', 'BasketAttribute') ...@@ -23,6 +23,7 @@ BasketAttribute = get_model('basket', 'BasketAttribute')
BasketAttributeType = get_model('basket', 'BasketAttributeType') BasketAttributeType = get_model('basket', 'BasketAttributeType')
BUNDLE = 'bundle_identifier' BUNDLE = 'bundle_identifier'
ORGANIZATION_ATTRIBUTE_TYPE = 'organization' ORGANIZATION_ATTRIBUTE_TYPE = 'organization'
ENTERPRISE_CATALOG_ATTRIBUTE_TYPE = 'enterprise_catalog_uuid'
StockRecord = get_model('partner', 'StockRecord') StockRecord = get_model('partner', 'StockRecord')
OrderLine = get_model('order', 'Line') OrderLine = get_model('order', 'Line')
Refund = get_model('refund', 'Refund') Refund = get_model('refund', 'Refund')
...@@ -60,6 +61,7 @@ def prepare_basket(request, products, voucher=None): ...@@ -60,6 +61,7 @@ def prepare_basket(request, products, voucher=None):
basket (Basket): Contains the product to be redeemed and the Voucher applied. basket (Basket): Contains the product to be redeemed and the Voucher applied.
""" """
basket = Basket.get_basket(request.user, request.site) basket = Basket.get_basket(request.user, request.site)
basket_add_enterprise_catalog_attribute(basket, request.GET)
basket.flush() basket.flush()
basket.save() basket.save()
basket_addition = get_class('basket.signals', 'basket_addition') basket_addition = get_class('basket.signals', 'basket_addition')
...@@ -268,6 +270,35 @@ def basket_add_organization_attribute(basket, request_data): ...@@ -268,6 +270,35 @@ def basket_add_organization_attribute(basket, request_data):
) )
@newrelic.agent.function_trace()
def basket_add_enterprise_catalog_attribute(basket, request_data):
"""
Add enterprise catalog UUID attribute on basket, if the catalog UUID value
is provided in the request.
Arguments:
basket(Basket): order basket
request_data (dict): HttpRequest data
"""
# Value of enterprise catalog UUID is being passed as `catalog` from
# basket page
enterprise_catalog_uuid = request_data.get('catalog') if request_data else None
enterprise_catalog_attribute, __ = BasketAttributeType.objects.get_or_create(
name=ENTERPRISE_CATALOG_ATTRIBUTE_TYPE
)
if enterprise_catalog_uuid:
BasketAttribute.objects.update_or_create(
basket=basket,
attribute_type=enterprise_catalog_attribute,
defaults={
'value_text': enterprise_catalog_uuid.strip()}
)
else:
# Remove the enterprise catalog attribute for future update in basket
BasketAttribute.objects.filter(basket=basket, attribute_type=enterprise_catalog_attribute).delete()
def _set_basket_bundle_status(bundle, basket): def _set_basket_bundle_status(bundle, basket):
""" """
Sets the basket's bundle status Sets the basket's bundle status
......
...@@ -8,11 +8,10 @@ from django.urls import reverse ...@@ -8,11 +8,10 @@ from django.urls import reverse
from oscar.core.loading import get_model from oscar.core.loading import get_model
from oscar.test import factories from oscar.test import factories
from ecommerce.core.url_utils import get_lms_courseware_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.coupons.tests.mixins import DiscoveryMockMixin from ecommerce.coupons.tests.mixins import DiscoveryMockMixin
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.extensions.checkout.exceptions import BasketNotFreeError from ecommerce.extensions.checkout.exceptions import BasketNotFreeError
from ecommerce.extensions.checkout.utils import get_receipt_page_url
from ecommerce.extensions.checkout.views import ReceiptResponseView from ecommerce.extensions.checkout.views import ReceiptResponseView
from ecommerce.extensions.refund.tests.mixins import RefundTestMixin from ecommerce.extensions.refund.tests.mixins import RefundTestMixin
from ecommerce.tests.mixins import LmsApiMockMixin from ecommerce.tests.mixins import LmsApiMockMixin
...@@ -87,19 +86,14 @@ class FreeCheckoutViewTests(EnterpriseServiceMockMixin, TestCase): ...@@ -87,19 +86,14 @@ class FreeCheckoutViewTests(EnterpriseServiceMockMixin, TestCase):
self.assertRedirects(response, expected_url, fetch_redirect_response=False) self.assertRedirects(response, expected_url, fetch_redirect_response=False)
@httpretty.activate @httpretty.activate
def test_successful_redirect(self): def test_successful_redirect_dashboard(self):
""" Verify redirect to the receipt page. """ """ Verify redirect to the dashboard page. """
self.prepare_basket(0) self.prepare_basket(0)
self.assertEqual(Order.objects.count(), 0) self.assertEqual(Order.objects.count(), 0)
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(Order.objects.count(), 1) self.assertEqual(Order.objects.count(), 1)
order = Order.objects.first() self.assertRedirects(response, get_lms_dashboard_url(), fetch_redirect_response=False)
expected_url = get_receipt_page_url(
order_number=order.number,
site_configuration=order.site.siteconfiguration
)
self.assertRedirects(response, expected_url, fetch_redirect_response=False)
class CancelCheckoutViewTests(TestCase): class CancelCheckoutViewTests(TestCase):
......
...@@ -21,7 +21,6 @@ from ecommerce.core.url_utils import ( ...@@ -21,7 +21,6 @@ from ecommerce.core.url_utils import (
from ecommerce.enterprise.utils import has_enterprise_offer from ecommerce.enterprise.utils import has_enterprise_offer
from ecommerce.extensions.checkout.exceptions import BasketNotFreeError from ecommerce.extensions.checkout.exceptions import BasketNotFreeError
from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin
from ecommerce.extensions.checkout.utils import get_receipt_page_url
Applicator = get_class('offer.applicator', 'Applicator') Applicator = get_class('offer.applicator', 'Applicator')
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
...@@ -52,7 +51,7 @@ class FreeCheckoutView(EdxOrderPlacementMixin, RedirectView): ...@@ -52,7 +51,7 @@ class FreeCheckoutView(EdxOrderPlacementMixin, RedirectView):
""" View to handle free checkouts. """ View to handle free checkouts.
Retrieves the user's basket and checks to see if the basket is free in which case Retrieves the user's basket and checks to see if the basket is free in which case
the user is redirected to the receipt page. Otherwise the user is redirected back the user is redirected to the LMS dashboard. Otherwise the user is redirected back
to the basket summary page. to the basket summary page.
""" """
...@@ -90,11 +89,7 @@ class FreeCheckoutView(EdxOrderPlacementMixin, RedirectView): ...@@ -90,11 +89,7 @@ class FreeCheckoutView(EdxOrderPlacementMixin, RedirectView):
course_run_id = order.lines.all()[:1].get().product.course.id course_run_id = order.lines.all()[:1].get().product.course.id
url = get_lms_courseware_url(course_run_id) url = get_lms_courseware_url(course_run_id)
else: else:
receipt_path = get_receipt_page_url( url = get_lms_dashboard_url()
order_number=order.number,
site_configuration=order.site.siteconfiguration
)
url = site.siteconfiguration.build_lms_url(receipt_path)
else: else:
# If a user's basket is empty redirect the user to the basket summary # If a user's basket is empty redirect the user to the basket summary
# page which displays the appropriate message for empty baskets. # page which displays the appropriate message for empty baskets.
......
...@@ -55,3 +55,5 @@ APPLE_PAY_CYBERSOURCE_CARD_TYPE_MAP = { ...@@ -55,3 +55,5 @@ APPLE_PAY_CYBERSOURCE_CARD_TYPE_MAP = {
STRIPE_CARD_TYPE_MAP = { STRIPE_CARD_TYPE_MAP = {
value['stripe_brand']: key for key, value in six.iteritems(CARD_TYPES) if 'stripe_brand' in value value['stripe_brand']: key for key, value in six.iteritems(CARD_TYPES) if 'stripe_brand' in value
} }
VOUCHER_VALIDATION_BEFORE_PAYMENT = 'voucher_validation_before_payment'
""" Tests of the Payment Views. """ """ Tests of the Payment Views. """
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime
import json import json
import ddt import ddt
...@@ -8,10 +9,12 @@ import mock ...@@ -8,10 +9,12 @@ import mock
import responses import responses
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from freezegun import freeze_time from freezegun import freeze_time
from oscar.apps.payment.exceptions import TransactionDeclined from oscar.apps.payment.exceptions import TransactionDeclined
from oscar.core.loading import get_class, get_model from oscar.core.loading import get_class, get_model
from oscar.test import factories from oscar.test import factories
from waffle.testutils import override_switch
from ecommerce.core.constants import ENROLLMENT_CODE_PRODUCT_CLASS_NAME, ENROLLMENT_CODE_SWITCH from ecommerce.core.constants import ENROLLMENT_CODE_PRODUCT_CLASS_NAME, ENROLLMENT_CODE_SWITCH
from ecommerce.core.models import BusinessClient from ecommerce.core.models import BusinessClient
...@@ -21,11 +24,12 @@ from ecommerce.courses.tests.factories import CourseFactory ...@@ -21,11 +24,12 @@ from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.extensions.api.serializers import OrderSerializer from ecommerce.extensions.api.serializers import OrderSerializer
from ecommerce.extensions.basket.utils import basket_add_organization_attribute from ecommerce.extensions.basket.utils import basket_add_organization_attribute
from ecommerce.extensions.order.constants import PaymentEventTypeName from ecommerce.extensions.order.constants import PaymentEventTypeName
from ecommerce.extensions.payment.constants import VOUCHER_VALIDATION_BEFORE_PAYMENT
from ecommerce.extensions.payment.exceptions import InvalidBasketError, InvalidSignatureError from ecommerce.extensions.payment.exceptions import InvalidBasketError, InvalidSignatureError
from ecommerce.extensions.payment.processors.cybersource import Cybersource from ecommerce.extensions.payment.processors.cybersource import Cybersource
from ecommerce.extensions.payment.tests.mixins import CybersourceMixin, CybersourceNotificationTestsMixin from ecommerce.extensions.payment.tests.mixins import CybersourceMixin, CybersourceNotificationTestsMixin
from ecommerce.extensions.payment.views.cybersource import CybersourceInterstitialView from ecommerce.extensions.payment.views.cybersource import CybersourceInterstitialView
from ecommerce.extensions.test.factories import create_basket from ecommerce.extensions.test.factories import create_basket, prepare_voucher
from ecommerce.invoice.models import Invoice from ecommerce.invoice.models import Invoice
from ecommerce.tests.testcases import TestCase from ecommerce.tests.testcases import TestCase
...@@ -36,9 +40,10 @@ Order = get_model('order', 'Order') ...@@ -36,9 +40,10 @@ Order = get_model('order', 'Order')
OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator') OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator')
PaymentEvent = get_model('order', 'PaymentEvent') PaymentEvent = get_model('order', 'PaymentEvent')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
Product = get_model('catalogue', 'Product')
Selector = get_class('partner.strategy', 'Selector') Selector = get_class('partner.strategy', 'Selector')
Source = get_model('payment', 'Source') Source = get_model('payment', 'Source')
Product = get_model('catalogue', 'Product') Voucher = get_model('voucher', 'Voucher')
post_checkout = get_class('checkout.signals', 'post_checkout') post_checkout = get_class('checkout.signals', 'post_checkout')
...@@ -79,6 +84,16 @@ class CybersourceSubmitViewTests(CybersourceMixin, TestCase): ...@@ -79,6 +84,16 @@ class CybersourceSubmitViewTests(CybersourceMixin, TestCase):
basket.thaw() basket.thaw()
return basket return basket
def _prepare_basket_for_voucher_validation_tests(self, voucher_start_date, voucher_end_date):
""" Prepares basket for voucher validation """
basket = Basket.objects.create(site=self.site, owner=self.user)
voucher, product = prepare_voucher(start_datetime=voucher_start_date, end_datetime=voucher_end_date)
basket.strategy = Selector().strategy()
basket.add_product(product)
basket.vouchers.add(voucher)
basket.thaw()
return basket
def assert_basket_retrieval_error(self, basket_id): def assert_basket_retrieval_error(self, basket_id):
error_msg = 'There was a problem retrieving your basket. Refresh the page to try again.' error_msg = 'There was a problem retrieving your basket. Refresh the page to try again.'
return self._assert_basket_error(basket_id, error_msg) return self._assert_basket_error(basket_id, error_msg)
...@@ -179,6 +194,59 @@ class CybersourceSubmitViewTests(CybersourceMixin, TestCase): ...@@ -179,6 +194,59 @@ class CybersourceSubmitViewTests(CybersourceMixin, TestCase):
errors = json.loads(response.content)['field_errors'] errors = json.loads(response.content)['field_errors']
self.assertIn(field, errors) self.assertIn(field, errors)
@override_switch(VOUCHER_VALIDATION_BEFORE_PAYMENT, active=True)
@ddt.data(
(now() - datetime.timedelta(days=3), 400),
(now() + datetime.timedelta(days=3), 200))
@ddt.unpack
def test_submit_view_fails_for_invalid_voucher(self, voucher_end_time, status_code):
""" Verify SubmitPaymentView fails if basket invalid voucher"""
# Create Basket and payment data
voucher_start_time = now() - datetime.timedelta(days=5)
basket = self._prepare_basket_for_voucher_validation_tests(voucher_start_time, voucher_end_time)
data = self._generate_data(basket.id)
response = self.client.post(self.path, data)
self.assertEqual(response.status_code, status_code)
self.assertEqual(response['content-type'], JSON)
@override_switch(VOUCHER_VALIDATION_BEFORE_PAYMENT, active=True)
@mock.patch(
'ecommerce.extensions.voucher.models.Voucher.is_available_to_user',
return_value=(False, None)
)
def test_submit_view_fails_if_voucher_not_available(self, mock_is_available_to_user):
""" Verify SubmitPaymentView fails if basket voucher not available to student"""
# Create Basket and payment data
voucher_start_time = now() - datetime.timedelta(days=1)
voucher_end_time = now() + datetime.timedelta(days=3)
basket = self._prepare_basket_for_voucher_validation_tests(voucher_start_time, voucher_end_time)
data = self._generate_data(basket.id)
response = self.client.post(self.path, data)
self.assertEqual(response.status_code, 400)
self.assertEqual(response['content-type'], JSON)
self.assertEqual(mock_is_available_to_user.call_count, 3)
@override_switch(VOUCHER_VALIDATION_BEFORE_PAYMENT, active=False)
def test_successful_submit_view_with_voucher_switch_disabled(self):
"""
Temporary test to confirm the problem with SubmitPaymentView
Accepting an invalid voucher when the waffle switch is False.
This will be cleaned up in LEARNER-5719.
"""
voucher_start_time = now() - datetime.timedelta(days=5)
voucher_end_time = now() - datetime.timedelta(days=3)
basket = self._prepare_basket_for_voucher_validation_tests(voucher_start_time, voucher_end_time)
data = self._generate_data(basket.id)
response = self.client.post(self.path, data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['content-type'], JSON)
@ddt.ddt @ddt.ddt
class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCase): class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCase):
......
...@@ -32,9 +32,11 @@ Order = get_model('order', 'Order') ...@@ -32,9 +32,11 @@ Order = get_model('order', 'Order')
PaymentEvent = get_model('order', 'PaymentEvent') PaymentEvent = get_model('order', 'PaymentEvent')
PaymentEventType = get_model('order', 'PaymentEventType') PaymentEventType = get_model('order', 'PaymentEventType')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
Product = get_model('catalogue', 'Product')
Selector = get_class('partner.strategy', 'Selector') Selector = get_class('partner.strategy', 'Selector')
SourceType = get_model('payment', 'SourceType') SourceType = get_model('payment', 'SourceType')
Product = get_model('catalogue', 'Product') Voucher = get_model('voucher', 'Voucher')
post_checkout = get_class('checkout.signals', 'post_checkout') post_checkout = get_class('checkout.signals', 'post_checkout')
......
...@@ -2,6 +2,7 @@ import abc ...@@ -2,6 +2,7 @@ import abc
import logging import logging
import six import six
import waffle
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import JsonResponse from django.http import JsonResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
...@@ -10,6 +11,7 @@ from django.views.generic import TemplateView, View ...@@ -10,6 +11,7 @@ from django.views.generic import TemplateView, View
from oscar.core.loading import get_class, get_model from oscar.core.loading import get_class, get_model
from ecommerce.core.url_utils import get_lms_url from ecommerce.core.url_utils import get_lms_url
from ecommerce.extensions.payment.constants import VOUCHER_VALIDATION_BEFORE_PAYMENT
from ecommerce.extensions.payment.forms import PaymentForm from ecommerce.extensions.payment.forms import PaymentForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -65,7 +67,7 @@ class BasePaymentSubmitView(View): ...@@ -65,7 +67,7 @@ class BasePaymentSubmitView(View):
form_kwargs = self.get_form_kwargs() form_kwargs = self.get_form_kwargs()
form = self.form_class(**form_kwargs) form = self.form_class(**form_kwargs)
if form.is_valid(): if form.is_valid() and self.check_valid_voucher():
return self.form_valid(form) return self.form_valid(form)
else: else:
return self.form_invalid(form) return self.form_invalid(form)
...@@ -77,6 +79,27 @@ class BasePaymentSubmitView(View): ...@@ -77,6 +79,27 @@ class BasePaymentSubmitView(View):
'request': self.request, 'request': self.request,
} }
def check_valid_voucher(self):
"""
LEARNER-5603 Hot fix
Learners are able to bypass the basket views and pay using the
expired vouchers. This will disable students from doing so.
(https://github.com/django-oscar/django-oscar/blob/master/src/oscar/apps/order/utils.py#L68-L70)
# TODO: LEARNER-5719: Clean-up validation code as needed after further investigation.
"""
if waffle.switch_is_active(VOUCHER_VALIDATION_BEFORE_PAYMENT):
basket = self.request.basket
for voucher in basket.vouchers.select_for_update():
available_to_user, __ = voucher.is_available_to_user(user=self.request.user)
if not available_to_user or not voucher.is_active():
logger.info(
'[%s] basket was checked out with invalid voucher [%s]',
basket.id,
voucher.code,
)
return False
return True
@abc.abstractmethod @abc.abstractmethod
def form_valid(self, form): def form_valid(self, form):
""" Perform payment processing after validating the form submission. """ """ Perform payment processing after validating the form submission. """
......
...@@ -145,13 +145,13 @@ define([ ...@@ -145,13 +145,13 @@ define([
var applePayBtn = document.getElementById('applePayBtn'); var applePayBtn = document.getElementById('applePayBtn');
if (canMakePayments) { if (canMakePayments) {
console.log('Learner is eligible for Apple Pay'); console.log('Learner is eligible for Apple Pay'); // eslint-disable-line no-console
// Display the button // Display the button
applePayBtn.style.display = 'inline-flex'; applePayBtn.style.display = 'inline-flex';
applePayBtn.addEventListener('click', self.onApplePayButtonClicked.bind(self)); applePayBtn.addEventListener('click', self.onApplePayButtonClicked.bind(self));
} else { } else {
console.log('Apple Pay not setup.'); console.log('Apple Pay not setup.'); // eslint-disable-line no-console
} }
} }
); );
...@@ -198,7 +198,7 @@ define([ ...@@ -198,7 +198,7 @@ define([
onApplePayValidateMerchant: function(event) { onApplePayValidateMerchant: function(event) {
var self = this; var self = this;
console.log('Validating merchant...'); console.log('Validating merchant...'); // eslint-disable-line no-console
$.ajax({ $.ajax({
method: 'POST', method: 'POST',
...@@ -209,17 +209,17 @@ define([ ...@@ -209,17 +209,17 @@ define([
data: JSON.stringify({url: event.validationURL}), data: JSON.stringify({url: event.validationURL}),
contentType: 'application/json', contentType: 'application/json',
success: function(data) { success: function(data) {
console.log('Merchant validation succeeded.'); console.log('Merchant validation succeeded.'); // eslint-disable-line no-console
console.log(data); console.log(data); // eslint-disable-line no-console
self.applePaySession.completeMerchantValidation(data); self.applePaySession.completeMerchantValidation(data);
}, },
error: function(jqXHR, textStatus, errorThrown) { error: function(jqXHR, textStatus, errorThrown) {
// Translators: Do not translate "Apple Pay". // Translators: Do not translate "Apple Pay".
var msg = gettext('Apple Pay is not available at this time. Please try another payment method.'); var msg = gettext('Apple Pay is not available at this time. Please try another payment method.');
console.log('Merchant validation failed!'); console.log('Merchant validation failed!'); // eslint-disable-line no-console
console.log(textStatus); console.log(textStatus); // eslint-disable-line no-console
console.log(errorThrown); console.log(errorThrown); // eslint-disable-line no-console
self.applePaySession.abort(); self.applePaySession.abort();
self.displayErrorMessage(msg); self.displayErrorMessage(msg);
...@@ -229,7 +229,7 @@ define([ ...@@ -229,7 +229,7 @@ define([
onApplePayPaymentAuthorized: function(event) { onApplePayPaymentAuthorized: function(event) {
var self = this; var self = this;
console.log('Submitting Apple Pay payment to CyberSource...'); console.log('Submitting Apple Pay payment to CyberSource...'); // eslint-disable-line no-console
$.ajax({ $.ajax({
method: 'POST', method: 'POST',
...@@ -240,8 +240,7 @@ define([ ...@@ -240,8 +240,7 @@ define([
data: JSON.stringify(event.payment), data: JSON.stringify(event.payment),
contentType: 'application/json', contentType: 'application/json',
success: function(data) { success: function(data) {
console.log('Successfully submitted Apple Pay payment to CyberSource.'); console.log(data); // eslint-disable-line no-console
console.log(data);
self.applePaySession.completePayment(ApplePaySession.STATUS_SUCCESS); self.applePaySession.completePayment(ApplePaySession.STATUS_SUCCESS);
self.redirectToReceipt(data.number); self.redirectToReceipt(data.number);
}, },
...@@ -249,9 +248,8 @@ define([ ...@@ -249,9 +248,8 @@ define([
var msg = gettext('An error occurred while processing your payment. You have NOT been charged. ' + var msg = gettext('An error occurred while processing your payment. You have NOT been charged. ' +
'Please try again, or select another payment method.'); 'Please try again, or select another payment method.');
console.log('Failed to submit Apple Pay payment to CyberSource!'); console.log(textStatus); // eslint-disable-line no-console
console.log(textStatus); console.log(errorThrown); // eslint-disable-line no-console
console.log(errorThrown);
self.applePaySession.completePayment(ApplePaySession.STATUS_FAILURE); self.applePaySession.completePayment(ApplePaySession.STATUS_FAILURE);
self.displayErrorMessage(msg); self.displayErrorMessage(msg);
} }
......
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