Commit 4a43474a by Renzo Lucioni

Merge pull request #59 from edx/renzo/basket-creation-endpoint

Basket creation endpoint
parents 542e5f48 6c2ea197
...@@ -12,6 +12,8 @@ omit = ecommerce/settings* ...@@ -12,6 +12,8 @@ omit = ecommerce/settings*
# The fulfillment app's status module only contains constants, which don't require # The fulfillment app's status module only contains constants, which don't require
# test coverage. # test coverage.
ecommerce/extensions/fulfillment/status* ecommerce/extensions/fulfillment/status*
# Temporary exclusion, while tests that excercise this code are developed.
ecommerce/extensions/checkout/mixins*
*wsgi.py *wsgi.py
*migrations* *migrations*
*admin.py *admin.py
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Unit tests for the analytics app.""" """Unit tests for the analytics app."""
from django.apps import apps from django.apps import apps
from django.test.utils import override_settings from django.test import TestCase, override_settings
from oscar.core.loading import get_model from oscar.core.loading import get_model
from ecommerce.extensions.api.tests.test_integration import OrdersIntegrationTests from ecommerce.tests.mixins import BasketCreationMixin
ProductRecord = get_model('analytics', 'ProductRecord') ProductRecord = get_model('analytics', 'ProductRecord')
class AnalyticsTests(OrdersIntegrationTests): class AnalyticsTests(BasketCreationMixin, TestCase):
"""Test analytics behavior in controlled scenarios.""" """Test analytics behavior in controlled scenarios."""
def setUp(self):
super(AnalyticsTests, self).setUp()
@override_settings(INSTALL_DEFAULT_ANALYTICS_RECEIVERS=False) @override_settings(INSTALL_DEFAULT_ANALYTICS_RECEIVERS=False)
def test_order_receiver_disabled(self): def test_order_receiver_disabled(self):
"""Verify that Oscar's Analytics order receiver can be disabled.""" """Verify that Oscar's Analytics order receiver can be disabled."""
self._initialize() self._initialize()
self._create_and_verify_order(self.FREE_TRIAL_SKU)
self.assert_successful_basket_creation(skus=[self.FREE_SKU], checkout=True)
# Verify that no product records are kept # Verify that no product records are kept
self.assertFalse(ProductRecord.objects.all().exists()) self.assertFalse(ProductRecord.objects.all().exists())
...@@ -25,10 +29,11 @@ class AnalyticsTests(OrdersIntegrationTests): ...@@ -25,10 +29,11 @@ class AnalyticsTests(OrdersIntegrationTests):
def test_order_receiver_enabled(self): def test_order_receiver_enabled(self):
"""Verify that Oscar's Analytics order receiver can be re-enabled.""" """Verify that Oscar's Analytics order receiver can be re-enabled."""
self._initialize() self._initialize()
self._create_and_verify_order(self.FREE_TRIAL_SKU)
self.assert_successful_basket_creation(skus=[self.FREE_SKU], checkout=True)
# Verify that product order counts are recorded # Verify that product order counts are recorded
product = ProductRecord.objects.get(product=self.free_trial) product = ProductRecord.objects.get(product=self.free_product)
self.assertEqual(product.num_purchases, 1) self.assertEqual(product.num_purchases, 1)
def _initialize(self): def _initialize(self):
......
from oscar.core.application import Application
from ecommerce.extensions.api.urls import urlpatterns
class ApiApplication(Application):
"""API application class.
This subclasses Oscar's base application class to create a custom
container for the API's URLs, views, and permissions.
"""
def get_urls(self):
"""Returns the URL patterns for the API."""
return self.post_process_urls(urlpatterns)
application = ApiApplication()
"""Ecommerce API constants."""
class APIDictionaryKeys(object):
"""Dictionary keys used repeatedly in the ecommerce API."""
BASKET_ID = u'id'
CHECKOUT = u'checkout'
ORDER = u'order'
ORDER_NUMBER = u'number'
ORDER_TOTAL = u'total'
PAYMENT_DATA = u'payment_data'
PAYMENT_FORM_DATA = u'payment_form_data'
PAYMENT_PAGE_URL = u'payment_page_url'
PAYMENT_PROCESSOR_NAME = u'payment_processor_name'
PRODUCTS = u'products'
SHIPPING_CHARGE = u'shipping_charge'
SHIPPING_METHOD = u'shipping_method'
SKU = u'sku'
class APIConstants(object):
"""Constants used throughout the ecommerce API."""
FREE = 0
KEYS = APIDictionaryKeys()
"""Functions used for data retrieval and manipulation by the API.""" """Functions used for data retrieval and manipulation by the API."""
from oscar.core.loading import get_model, get_class from oscar.core.loading import get_model, get_class
from ecommerce.extensions.api import errors from ecommerce.extensions.api import exceptions
from ecommerce.extensions.api.constants import APIConstants as AC
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
Product = get_model('catalogue', 'Product') Product = get_model('catalogue', 'Product')
ShippingEventType = get_model('order', 'ShippingEventType')
Selector = get_class('partner.strategy', 'Selector') Selector = get_class('partner.strategy', 'Selector')
Free = get_class('shipping.methods', 'Free')
OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator')
OrderTotalCalculator = get_class('checkout.calculators', 'OrderTotalCalculator')
def get_basket(user): def get_basket(user):
...@@ -38,16 +40,31 @@ def get_product(sku): ...@@ -38,16 +40,31 @@ def get_product(sku):
try: try:
return Product.objects.get(stockrecords__partner_sku=sku) return Product.objects.get(stockrecords__partner_sku=sku)
except Product.DoesNotExist: except Product.DoesNotExist:
raise errors.ProductNotFoundError( raise exceptions.ProductNotFoundError(
errors.PRODUCT_NOT_FOUND_DEVELOPER_MESSAGE.format(sku=sku) exceptions.PRODUCT_NOT_FOUND_DEVELOPER_MESSAGE.format(sku=sku)
) )
def get_shipping_event_type(name): def get_order_metadata(basket):
"""Retrieve the shipping event type corresponding to the provided name.""" """Retrieve information required to place an order.
try:
return ShippingEventType.objects.get(name=name) Arguments:
except ShippingEventType.DoesNotExist: basket (Basket): The basket whose contents are to be ordered.
raise errors.ShippingEventNotFoundError(
errors.SHIPPING_EVENT_NOT_FOUND_MESSAGE.format(name=name) Returns:
) dict: Containing an order number, a shipping method, a shipping charge,
and a Price object representing the order total.
"""
number = OrderNumberGenerator().order_number(basket)
shipping_method = Free()
shipping_charge = shipping_method.calculate(basket)
total = OrderTotalCalculator().calculate(basket, shipping_charge)
metadata = {
AC.KEYS.ORDER_NUMBER: number,
AC.KEYS.SHIPPING_METHOD: shipping_method,
AC.KEYS.SHIPPING_CHARGE: shipping_charge,
AC.KEYS.ORDER_TOTAL: total,
}
return metadata
"""Exceptions and error messages used by the API."""
from django.utils.translation import ugettext_lazy as _
SKU_NOT_FOUND_DEVELOPER_MESSAGE = u"No SKU present in POST data"
SKU_NOT_FOUND_USER_MESSAGE = _("We couldn't find the identification code necessary to look up your product.")
PRODUCT_NOT_FOUND_DEVELOPER_MESSAGE = u"Catalog does not contain the indicated product [SKU: {sku}]"
PRODUCT_NOT_FOUND_USER_MESSAGE = _("We couldn't find the product you're looking for.")
SHIPPING_EVENT_NOT_FOUND_MESSAGE = u"No shipping event [{name}] was found"
PRODUCT_UNAVAILABLE_USER_MESSAGE = _("The product you're trying to order is unavailable.")
class ApiError(Exception):
"""Standard error raised by the API."""
pass
class OrderError(ApiError):
"""Standard error raised by the orders endpoint.
Indicative of a general error when attempting to add a product to the
user's basket or turning that basket into an order.
"""
pass
class ProductNotFoundError(OrderError):
"""Raised when the provided SKU does not correspond to a product in the catalog."""
pass
class ShippingEventNotFoundError(OrderError):
"""Raised when a shipping event cannot be found by name."""
pass
"""Exceptions and error messages used by the ecommerce API."""
from django.utils.translation import ugettext_lazy as _
PRODUCT_OBJECTS_MISSING_DEVELOPER_MESSAGE = u"No product objects could be found in the request body"
PRODUCT_OBJECTS_MISSING_USER_MESSAGE = _("You can't check out with an empty basket.")
SKU_NOT_FOUND_DEVELOPER_MESSAGE = u"SKU missing from a requested product object"
SKU_NOT_FOUND_USER_MESSAGE = _("We couldn't locate the identification code necessary to find one of your products.")
PRODUCT_NOT_FOUND_DEVELOPER_MESSAGE = u"Catalog does not contain a product with SKU [{sku}]"
PRODUCT_NOT_FOUND_USER_MESSAGE = _("We couldn't find one of the products you're looking for.")
PRODUCT_UNAVAILABLE_DEVELOPER_MESSAGE = u"Product with SKU [{sku}] is [{availability}]"
PRODUCT_UNAVAILABLE_USER_MESSAGE = _("One of the products you're trying to order is unavailable.")
class ApiError(Exception):
"""Standard error raised by the API."""
pass
class ProductNotFoundError(ApiError):
"""Raised when the provided SKU does not correspond to a product in the catalog."""
pass
...@@ -4,7 +4,32 @@ from decimal import Decimal as D ...@@ -4,7 +4,32 @@ from decimal import Decimal as D
from rest_framework import serializers from rest_framework import serializers
from ecommerce.extensions.payment.serializers import SourceSerializer
class TransactionSerializer(serializers.Serializer):
"""Serializes a transaction. """
txn_type = serializers.CharField(max_length=128)
amount = serializers.DecimalField(decimal_places=2, max_digits=12)
reference = serializers.CharField(max_length=128)
status = serializers.CharField(max_length=128)
date_created = serializers.DateTimeField()
class SourceTypeSerializer(serializers.Serializer):
"""Serializes the payment source type. """
name = serializers.CharField(max_length=128)
code = serializers.CharField(max_length=128)
class SourceSerializer(serializers.Serializer):
"""Serializes a payment source. """
source_type = SourceTypeSerializer()
transactions = TransactionSerializer(many=True)
currency = serializers.CharField(max_length=12)
amount_allocated = serializers.DecimalField(decimal_places=2, max_digits=12)
amount_debited = serializers.DecimalField(decimal_places=2, max_digits=12)
amount_refunded = serializers.DecimalField(decimal_places=2, max_digits=12)
reference = serializers.CharField(max_length=128)
label = serializers.CharField(max_length=128)
class CountrySerializer(serializers.Serializer): class CountrySerializer(serializers.Serializer):
...@@ -63,12 +88,10 @@ class OrderSerializer(serializers.Serializer): ...@@ -63,12 +88,10 @@ class OrderSerializer(serializers.Serializer):
) )
lines = LinesSerializer(many=True) lines = LinesSerializer(many=True)
billing_address = BillingAddressSerializer(allow_null=True) billing_address = BillingAddressSerializer(allow_null=True)
payment_processor = serializers.CharField(max_length=32)
class PaymentProcessorSerializer(serializers.Serializer): class PaymentProcessorSerializer(serializers.Serializer):
""" Serializer to use with instances of processors.BasePaymentProcessor """ """ Serializer to use with instances of processors.BasePaymentProcessor """
def to_representation(self, instance): def to_representation(self, instance):
""" Serialize instances as a string instead of a mapping object. """ """ Serialize instances as a string instead of a mapping object. """
return instance.NAME return instance.NAME
import json import json
from django.conf import settings from django.conf import settings
from django.test import TestCase, override_settings, RequestFactory
import httpretty import httpretty
from django.test import TestCase, override_settings, RequestFactory
from oscar.test import factories from oscar.test import factories
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
......
"""Throttles for rate-limiting requests to API endpoints."""
from django.conf import settings
from rest_framework.throttling import UserRateThrottle
class OrdersThrottle(UserRateThrottle):
"""Limit the number of requests users can make to the orders endpoint."""
rate = getattr(settings, 'ORDERS_ENDPOINT_RATE_LIMIT', '40/minute')
from django.conf.urls import patterns, url, include from django.conf.urls import patterns, url, include
from ecommerce.extensions.api import views
ORDER_NUMBER_PATTERN = r"(?P<number>[-\w]+)"
BASKET_ID_PATTERN = r"(?P<basket_id>[\w]+)"
ORDER_URLS = patterns(
'',
url(r'^$', views.OrderListCreateAPIView.as_view(), name='create_list'),
url(
r'^{number}/$'.format(number=ORDER_NUMBER_PATTERN),
views.RetrieveOrderView.as_view(),
name='retrieve'
),
url(
r'^{number}/fulfill/$'.format(number=ORDER_NUMBER_PATTERN),
views.FulfillOrderView.as_view(),
name='fulfill'
),
)
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^processors/$', views.PaymentProcessorsView.as_view(), name='payment_processors'), url(r'^v1/', include('ecommerce.extensions.api.v1.urls', namespace='v1')),
url(r'^orders/', include(ORDER_URLS, namespace='orders')), url(r'^v2/', include('ecommerce.extensions.api.v2.urls', namespace='v2')),
url(
r'^baskets/{basket_id}/order/$'.format(basket_id=BASKET_ID_PATTERN),
views.RetrieveOrderByBasketView.as_view(),
name='order_by_basket'
),
) )
...@@ -68,7 +68,7 @@ class OrdersIntegrationTests(TestCase): ...@@ -68,7 +68,7 @@ class OrdersIntegrationTests(TestCase):
data = {'sku': sku} data = {'sku': sku}
token = jwt.encode(self.USER_DATA, self.JWT_SECRET_KEY) token = jwt.encode(self.USER_DATA, self.JWT_SECRET_KEY)
response = self.client.post(reverse('orders:create_list'), data, HTTP_AUTHORIZATION='JWT ' + token) response = self.client.post(reverse('api:v1:orders:create_list'), data, HTTP_AUTHORIZATION='JWT ' + token)
return response return response
......
from django.conf.urls import patterns, url, include
from ecommerce.extensions.api.v1 import views
ORDER_NUMBER_PATTERN = r'(?P<number>[-\w]+)'
ORDER_URLS = patterns(
'',
url(r'^$', views.OrderListCreateAPIView.as_view(), name='create_list'),
url(
r'^{number}/$'.format(number=ORDER_NUMBER_PATTERN),
views.RetrieveOrderView.as_view(),
name='retrieve'
),
url(
r'^{number}/fulfill/$'.format(number=ORDER_NUMBER_PATTERN),
views.OrderFulfillView.as_view(),
name='fulfill'
),
)
urlpatterns = patterns(
'',
url(r'^orders/', include(ORDER_URLS, namespace='orders'))
)
...@@ -5,12 +5,11 @@ from django.conf import settings ...@@ -5,12 +5,11 @@ from django.conf import settings
from django.http import Http404 from django.http import Http404
from oscar.core.loading import get_class, get_classes, get_model from oscar.core.loading import get_class, get_classes, get_model
from rest_framework import status from rest_framework import status
from rest_framework.generics import UpdateAPIView, RetrieveAPIView, ListCreateAPIView, ListAPIView from rest_framework.generics import UpdateAPIView, RetrieveAPIView, ListCreateAPIView
from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions
from rest_framework.response import Response from rest_framework.response import Response
from ecommerce.extensions.api import data, errors, serializers from ecommerce.extensions.api import data, exceptions, serializers
from ecommerce.extensions.api.throttling import OrdersThrottle
from ecommerce.extensions.fulfillment.status import ORDER from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin
from ecommerce.extensions.payment.helpers import get_processor_class from ecommerce.extensions.payment.helpers import get_processor_class
...@@ -64,7 +63,6 @@ class RetrieveOrderView(RetrieveAPIView): ...@@ -64,7 +63,6 @@ class RetrieveOrderView(RetrieveAPIView):
"total_excl_tax": 0.0 "total_excl_tax": 0.0
}' }'
""" """
throttle_classes = (OrdersThrottle,)
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
serializer_class = serializers.OrderSerializer serializer_class = serializers.OrderSerializer
lookup_field = 'number' lookup_field = 'number'
...@@ -91,22 +89,12 @@ class RetrieveOrderView(RetrieveAPIView): ...@@ -91,22 +89,12 @@ class RetrieveOrderView(RetrieveAPIView):
raise Http404 raise Http404
class RetrieveOrderByBasketView(RetrieveOrderView):
""" Allow the viewing of Orders by Basket.
Works exactly the same as RetrieveOrderView, except that orders are looked
up via the id of the related basket.
"""
lookup_field = 'basket_id'
class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView): class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView):
""" """
Endpoint for listing or creating orders. Endpoint for listing or creating orders.
When listing orders, results are ordered with the newest order being the first in the list of results. When listing orders, results are ordered with the newest order being the first in the list of results.
""" """
throttle_classes = (OrdersThrottle,)
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
serializer_class = serializers.OrderSerializer serializer_class = serializers.OrderSerializer
...@@ -179,12 +167,12 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView): ...@@ -179,12 +167,12 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView):
if sku: if sku:
try: try:
product = data.get_product(sku) product = data.get_product(sku)
except errors.ProductNotFoundError as error: except exceptions.ProductNotFoundError as error:
return self._report_bad_request(error.message, errors.PRODUCT_NOT_FOUND_USER_MESSAGE) return self._report_bad_request(error.message, exceptions.PRODUCT_NOT_FOUND_USER_MESSAGE)
else: else:
return self._report_bad_request( return self._report_bad_request(
errors.SKU_NOT_FOUND_DEVELOPER_MESSAGE, exceptions.SKU_NOT_FOUND_DEVELOPER_MESSAGE,
errors.SKU_NOT_FOUND_USER_MESSAGE exceptions.SKU_NOT_FOUND_USER_MESSAGE
) )
basket = data.get_basket(request.user) basket = data.get_basket(request.user)
...@@ -195,7 +183,13 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView): ...@@ -195,7 +183,13 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView):
# user attempts to order again, the `get_basket` utility will merge all old # user attempts to order again, the `get_basket` utility will merge all old
# baskets with a new one, returning a fresh basket. # baskets with a new one, returning a fresh basket.
if not availability.is_available_to_buy: if not availability.is_available_to_buy:
return self._report_bad_request(availability.message, errors.PRODUCT_UNAVAILABLE_USER_MESSAGE) return self._report_bad_request(
exceptions.PRODUCT_UNAVAILABLE_DEVELOPER_MESSAGE.format(
sku=sku,
availability=availability.message
),
exceptions.PRODUCT_UNAVAILABLE_USER_MESSAGE
)
payment_processor = get_processor_class(settings.PAYMENT_PROCESSORS[0]) payment_processor = get_processor_class(settings.PAYMENT_PROCESSORS[0])
...@@ -208,7 +202,7 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView): ...@@ -208,7 +202,7 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView):
order.currency, order.currency,
) )
order = self._fulfill_order(order) order = self.fulfill_order(order)
order_data = self._assemble_order_data(order, payment_processor) order_data = self._assemble_order_data(order, payment_processor)
...@@ -253,8 +247,7 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView): ...@@ -253,8 +247,7 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView):
shipping_charge, shipping_charge,
user=basket.owner, user=basket.owner,
order_number=OrderNumberGenerator.order_number(basket), order_number=OrderNumberGenerator.order_number(basket),
status=ORDER.OPEN, status=ORDER.OPEN
payment_processor=payment_processor.NAME
) )
logger.info( logger.info(
...@@ -289,7 +282,7 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView): ...@@ -289,7 +282,7 @@ class OrderListCreateAPIView(FulfillmentMixin, ListCreateAPIView):
return order_data return order_data
class FulfillOrderView(FulfillmentMixin, UpdateAPIView): class OrderFulfillView(FulfillmentMixin, UpdateAPIView):
permission_classes = (IsAuthenticated, DjangoModelPermissions,) permission_classes = (IsAuthenticated, DjangoModelPermissions,)
lookup_field = 'number' lookup_field = 'number'
queryset = Order.objects.all() queryset = Order.objects.all()
...@@ -302,7 +295,7 @@ class FulfillOrderView(FulfillmentMixin, UpdateAPIView): ...@@ -302,7 +295,7 @@ class FulfillOrderView(FulfillmentMixin, UpdateAPIView):
return Response(status=status.HTTP_406_NOT_ACCEPTABLE) return Response(status=status.HTTP_406_NOT_ACCEPTABLE)
logger.info('Retrying fulfillment of order [%s]...', order.number) logger.info('Retrying fulfillment of order [%s]...', order.number)
order = self._fulfill_order(order) order = self.fulfill_order(order)
if order.can_retry_fulfillment: if order.can_retry_fulfillment:
logger.warning('Fulfillment of order [%s] failed!', order.number) logger.warning('Fulfillment of order [%s] failed!', order.number)
...@@ -310,14 +303,3 @@ class FulfillOrderView(FulfillmentMixin, UpdateAPIView): ...@@ -310,14 +303,3 @@ class FulfillOrderView(FulfillmentMixin, UpdateAPIView):
serializer = self.get_serializer(order) serializer = self.get_serializer(order)
return Response(serializer.data) return Response(serializer.data)
class PaymentProcessorsView(ListAPIView):
""" View that lists the available payment processors. """
pagination_class = None
permission_classes = (IsAuthenticated,)
serializer_class = serializers.PaymentProcessorSerializer
def get_queryset(self):
""" Fetch the list of payment processor classes based on django settings."""
return [get_processor_class(path) for path in settings.PAYMENT_PROCESSORS]
from django.conf.urls import patterns, url, include
from ecommerce.extensions.api.v2 import views
ORDER_NUMBER_PATTERN = r'(?P<number>[-\w]+)'
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
BASKET_URLS = patterns(
'',
url(r'^$', views.BasketCreateView.as_view(), name='create'),
url(
r'^{basket_id}/order/$'.format(basket_id=BASKET_ID_PATTERN),
views.OrderByBasketRetrieveView.as_view(),
name='retrieve_order'
),
)
ORDER_URLS = patterns(
'',
url(r'^$', views.OrderListView.as_view(), name='list'),
url(
r'^{number}/$'.format(number=ORDER_NUMBER_PATTERN),
views.OrderRetrieveView.as_view(),
name='retrieve'
),
url(
r'^{number}/fulfill/$'.format(number=ORDER_NUMBER_PATTERN),
views.OrderFulfillView.as_view(),
name='fulfill'
),
)
PAYMENT_URLS = patterns(
'',
url(r'^processors/$', views.PaymentProcessorListView.as_view(), name='list_processors'),
)
urlpatterns = patterns(
'',
url(r'^baskets/', include(BASKET_URLS, namespace='baskets')),
url(r'^orders/', include(ORDER_URLS, namespace='orders')),
url(r'^payment/', include(PAYMENT_URLS, namespace='payment')),
)
from oscar.core.loading import get_model
from oscar.apps.checkout.mixins import OrderPlacementMixin
from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin
from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.extensions.payment.constants import ProcessorConstants as PC
Source = get_model('payment', 'Source')
SourceType = get_model('payment', 'SourceType')
class EdxOrderPlacementMixin(OrderPlacementMixin, FulfillmentMixin):
"""Mixin which provides functionality for placing orders.
Any view class which needs to place an order should use this mixin.
"""
def handle_payment(self, payment_processor, reference, total): # pylint: disable=arguments-differ
"""Handle payment processing and record payment sources and events.
This method is responsible for handling payment and recording the
payment sources (using the add_payment_source method) and payment
events (using add_payment_event) so they can be linked to the order
when it is saved later on.
In the below, let O represent an order yet to be created.
Arguments:
payment_processor (BasePaymentProcessor): The payment processor
responsible for handling transactions which allow for the
placement of O.
reference (unicode): Identifier representing a unique charge in the
payment processor's system which allows the placement of O.
total (Price): Represents the amount of money which changed hands in
order to allow the placement of O.
Returns:
None
"""
# NOTE: If the payment processor in use requires us to explicitly clear
# authorized transactions (e.g., PayPal), this method should be modified to
# perform any necessary requests.
source_type, __ = SourceType.objects.get_or_create(name=payment_processor.NAME)
source = Source(
source_type=source_type,
reference=reference,
amount_allocated=total.excl_tax
)
self.add_payment_source(source)
# Record payment event
self.add_payment_event(
PC.SETTLEMENT,
total.excl_tax,
reference=reference
)
def handle_successful_order(self, order):
"""Take any actions required after an order has been successfully placed.
This system is currently designed to sell digital products, so this method
attempts to immediately fulfill newly-placed orders.
"""
return self.fulfill_order(order)
def get_initial_order_status(self, basket):
"""Returns the state in which newly-placed orders are expected to be."""
return ORDER.OPEN
...@@ -6,7 +6,7 @@ from selenium.common.exceptions import NoSuchElementException ...@@ -6,7 +6,7 @@ from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.firefox.webdriver import WebDriver from selenium.webdriver.firefox.webdriver import WebDriver
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support.wait import WebDriverWait
from ecommerce.extensions.api.views import FulfillmentMixin from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin
from ecommerce.extensions.fulfillment.status import ORDER, LINE from ecommerce.extensions.fulfillment.status import ORDER, LINE
......
...@@ -10,7 +10,7 @@ import logging ...@@ -10,7 +10,7 @@ import logging
from django.conf import settings from django.conf import settings
from django.utils import importlib from django.utils import importlib
from ecommerce.extensions.fulfillment import errors from ecommerce.extensions.fulfillment import exceptions
from ecommerce.extensions.fulfillment.status import ORDER, LINE from ecommerce.extensions.fulfillment.status import ORDER, LINE
...@@ -39,7 +39,7 @@ def fulfill_order(order, lines): ...@@ -39,7 +39,7 @@ def fulfill_order(order, lines):
if ORDER.COMPLETE not in order.available_statuses(): if ORDER.COMPLETE not in order.available_statuses():
error_msg = "Order has a current status of [{status}] which cannot be fulfilled.".format(status=order.status) error_msg = "Order has a current status of [{status}] which cannot be fulfilled.".format(status=order.status)
logger.error(error_msg) logger.error(error_msg)
raise errors.IncorrectOrderStatusError(error_msg) raise exceptions.IncorrectOrderStatusError(error_msg)
modules = getattr(settings, 'FULFILLMENT_MODULES', {}) modules = getattr(settings, 'FULFILLMENT_MODULES', {})
# Construct a dict of lines by their product type. # Construct a dict of lines by their product type.
......
"""Errors thrown by the Fulfillment API.""" """Exceptions and error messages used by the fulfillment module."""
class FulfillmentError(Exception): class FulfillmentError(Exception):
""" Standard error for the Fulfillment API. """Standard error for the fulfillment module.
Indicates there was a general error with fulfillment or revoking a product. Indicates there was a general error with fulfillment or revoking a product.
""" """
pass pass
class FulfillmentConfigurationError(FulfillmentError): class FulfillmentConfigurationError(FulfillmentError):
""" Error for when the Fulfillment API is improperly configured. """Error for when the fulfillment module is improperly configured.
Indicates that the setup of the Fulfillment API is incorrect. This is likely due to tan incorrect Indicates that the setup of the fulfillment module is incorrect. This is likely due to tan incorrect
mapping of FulfillmentModules to Product Types. mapping of FulfillmentModules to Product Types.
""" """
pass pass
class IncorrectOrderStatusError(FulfillmentError): class IncorrectOrderStatusError(FulfillmentError):
""" Error indicating the Order status cannot be fulfilled. """Error indicating the Order status cannot be fulfilled.
Only orders in the current status can be moved to "Complete" or "Fulfillment Error". As such, it cannot Only orders in the current status can be moved to "Complete" or "Fulfillment Error". As such, it cannot
move a "Refunded" or "Open" Order to "Complete", i.e. fulfilling it. move a "Refunded" or "Open" Order to "Complete", i.e. fulfilling it.
""" """
pass pass
""" Mixins to support views that fulfill orders. """ """Mixins to support views that fulfill orders."""
from oscar.core.loading import get_model, get_class
from oscar.core.loading import get_class
from ecommerce.extensions.api import data
ShippingEventType = get_model('order', 'ShippingEventType')
EventHandler = get_class('order.processing', 'EventHandler') EventHandler = get_class('order.processing', 'EventHandler')
class FulfillmentMixin(object): class FulfillmentMixin(object):
""" A mixin that provides the ability to fulfill orders. """ """A mixin that provides the ability to fulfill orders."""
SHIPPING_EVENT_NAME = 'Shipped' SHIPPING_EVENT_NAME = 'Shipped'
def _fulfill_order(self, order): def fulfill_order(self, order):
"""Attempt fulfillment for an order.""" """Attempt fulfillment of an order."""
order_lines = order.lines.all() order_lines = order.lines.all()
line_quantities = [line.quantity for line in order_lines] line_quantities = [line.quantity for line in order_lines]
shipping_event = data.get_shipping_event_type(self.SHIPPING_EVENT_NAME) shipping_event = ShippingEventType.objects.get(name=self.SHIPPING_EVENT_NAME)
fulfilled_order = EventHandler().handle_shipping_event(order, shipping_event, order_lines, line_quantities) fulfilled_order = EventHandler().handle_shipping_event(order, shipping_event, order_lines, line_quantities)
return fulfilled_order return fulfilled_order
...@@ -9,7 +9,7 @@ from oscar.test import factories ...@@ -9,7 +9,7 @@ from oscar.test import factories
from ecommerce.extensions.fulfillment.modules import FulfillmentModule from ecommerce.extensions.fulfillment.modules import FulfillmentModule
from ecommerce.extensions.fulfillment import api as fulfillment_api from ecommerce.extensions.fulfillment import api as fulfillment_api
from ecommerce.extensions.fulfillment import errors from ecommerce.extensions.fulfillment import exceptions
from ecommerce.extensions.fulfillment.status import ORDER, LINE from ecommerce.extensions.fulfillment.status import ORDER, LINE
...@@ -78,7 +78,7 @@ class FulfillmentTest(TestCase): ...@@ -78,7 +78,7 @@ class FulfillmentTest(TestCase):
self.assertEquals(LINE.COMPLETE, self.order.lines.all()[0].status) self.assertEquals(LINE.COMPLETE, self.order.lines.all()[0].status)
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.test_api.FakeFulfillmentModule', ]) @override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.test_api.FakeFulfillmentModule', ])
@raises(errors.IncorrectOrderStatusError) @raises(exceptions.IncorrectOrderStatusError)
def test_bad_fulfillment_state(self): def test_bad_fulfillment_state(self):
"""Test a basic fulfillment of a Course Seat.""" """Test a basic fulfillment of a Course Seat."""
# Set the order to Refunded, which cannot be fulfilled. # Set the order to Refunded, which cannot be fulfilled.
......
...@@ -6,6 +6,7 @@ class ProcessorConstants(object): ...@@ -6,6 +6,7 @@ class ProcessorConstants(object):
ORDER_NUMBER = 'order_number' ORDER_NUMBER = 'order_number'
SUCCESS = 'success' SUCCESS = 'success'
PAID_EVENT_NAME = 'Paid' PAID_EVENT_NAME = 'Paid'
SETTLEMENT = u'settlement'
class CybersourceFieldNames(object): class CybersourceFieldNames(object):
......
"""Exceptions and error messages used by or related to payment processors."""
from django.utils.translation import ugettext_lazy as _
PROCESSOR_NOT_FOUND_DEVELOPER_MESSAGE = u"Lookup for a payment processor with name [{name}] failed"
PROCESSOR_NOT_FOUND_USER_MESSAGE = _("We don't support the payment option you selected.")
class ProcessorNotFoundError(Exception):
"""Raised when a requested payment processor cannot be found."""
pass
...@@ -3,8 +3,11 @@ import hmac ...@@ -3,8 +3,11 @@ import hmac
import base64 import base64
import hashlib import hashlib
from django.conf import settings
from django.utils import importlib from django.utils import importlib
from ecommerce.extensions.payment import exceptions
def get_processor_class(path): def get_processor_class(path):
"""Return the payment processor class at the specified path. """Return the payment processor class at the specified path.
...@@ -26,6 +29,44 @@ def get_processor_class(path): ...@@ -26,6 +29,44 @@ def get_processor_class(path):
return processor_class return processor_class
def get_default_processor_class():
"""Return the default payment processor class.
Returns:
class: The payment processor class located at the first path
specified in the PAYMENT_PROCESSORS setting.
Raises:
IndexError: If the PAYMENT_PROCESSORS setting is empty.
"""
processor_class = get_processor_class(settings.PAYMENT_PROCESSORS[0])
return processor_class
def get_processor_class_by_name(name):
"""Return the payment processor class corresponding to the specified name.
Arguments:
name (string): The name of a payment processor.
Returns:
class: The payment processor class with the given name.
Raises:
ProcessorNotFoundError: If no payment processor with the given name exists.
"""
for path in settings.PAYMENT_PROCESSORS:
processor_class = get_processor_class(path)
if name == processor_class.NAME:
return processor_class
raise exceptions.ProcessorNotFoundError(
exceptions.PROCESSOR_NOT_FOUND_DEVELOPER_MESSAGE.format(name=name)
)
def sign(message, secret): def sign(message, secret):
"""Compute a Base64-encoded HMAC-SHA256. """Compute a Base64-encoded HMAC-SHA256.
......
...@@ -28,7 +28,7 @@ class BasePaymentProcessor(object): ...@@ -28,7 +28,7 @@ class BasePaymentProcessor(object):
def get_transaction_parameters( def get_transaction_parameters(
self, self,
order, basket,
receipt_page_url=None, receipt_page_url=None,
cancel_page_url=None, cancel_page_url=None,
merchant_defined_data=None merchant_defined_data=None
...@@ -74,13 +74,14 @@ class Cybersource(BasePaymentProcessor): ...@@ -74,13 +74,14 @@ class Cybersource(BasePaymentProcessor):
self.profile_id = configuration['profile_id'] self.profile_id = configuration['profile_id']
self.access_key = configuration['access_key'] self.access_key = configuration['access_key']
self.secret_key = configuration['secret_key'] self.secret_key = configuration['secret_key']
self.payment_page_url = configuration['payment_page_url']
self.receipt_page_url = configuration['receipt_page_url'] self.receipt_page_url = configuration['receipt_page_url']
self.cancel_page_url = configuration['cancel_page_url'] self.cancel_page_url = configuration['cancel_page_url']
self.language_code = settings.LANGUAGE_CODE self.language_code = settings.LANGUAGE_CODE
def get_transaction_parameters( def get_transaction_parameters(
self, self,
order, basket,
receipt_page_url=None, receipt_page_url=None,
cancel_page_url=None, cancel_page_url=None,
merchant_defined_data=None merchant_defined_data=None
...@@ -88,7 +89,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -88,7 +89,7 @@ class Cybersource(BasePaymentProcessor):
"""Generate a dictionary of signed parameters CyberSource requires to complete a transaction. """Generate a dictionary of signed parameters CyberSource requires to complete a transaction.
Arguments: Arguments:
order (Order): The order whose line items are to be purchased as part of the transaction. basket (Basket): The basket whose line items are to be purchased as part of the transaction.
Keyword Arguments: Keyword Arguments:
receipt_page_url (unicode): If provided, overrides the receipt page URL on the Secure Acceptance receipt_page_url (unicode): If provided, overrides the receipt page URL on the Secure Acceptance
...@@ -104,7 +105,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -104,7 +105,7 @@ class Cybersource(BasePaymentProcessor):
dict: CyberSource-specific parameters required to complete a transaction, including a signature. dict: CyberSource-specific parameters required to complete a transaction, including a signature.
""" """
transaction_parameters = self._get_raw_transaction_parameters( transaction_parameters = self._get_raw_transaction_parameters(
order, basket,
receipt_page_url, receipt_page_url,
cancel_page_url, cancel_page_url,
merchant_defined_data merchant_defined_data
...@@ -157,7 +158,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -157,7 +158,7 @@ class Cybersource(BasePaymentProcessor):
finally: finally:
return result # pylint: disable=lost-exception return result # pylint: disable=lost-exception
def _get_raw_transaction_parameters(self, order, receipt_page_url, cancel_page_url, merchant_defined_data): def _get_raw_transaction_parameters(self, basket, receipt_page_url, cancel_page_url, merchant_defined_data):
"""Generate a dictionary of unsigned parameters CyberSource requires to complete a transaction. """Generate a dictionary of unsigned parameters CyberSource requires to complete a transaction.
The 'signed_field_names' parameter should be a string containing a comma-separated list of all keys in The 'signed_field_names' parameter should be a string containing a comma-separated list of all keys in
...@@ -165,7 +166,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -165,7 +166,7 @@ class Cybersource(BasePaymentProcessor):
to determine which parameters to sign, although the signing itself occurs separately. to determine which parameters to sign, although the signing itself occurs separately.
Arguments: Arguments:
order (Order): The order whose line items are to be purchased as part of the transaction. basket (Basket): The basket whose line items are to be purchased as part of the transaction.
receipt_page_url (unicode): Overrides the receipt page URL on the Secure Acceptance profile receipt_page_url (unicode): Overrides the receipt page URL on the Secure Acceptance profile
in use for this transaction. in use for this transaction.
cancel_page_url (unicode): Overrides the cancellation page URL on the Secure Acceptance profile cancel_page_url (unicode): Overrides the cancellation page URL on the Secure Acceptance profile
...@@ -199,7 +200,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -199,7 +200,7 @@ class Cybersource(BasePaymentProcessor):
# authorize through Secure Acceptance, then later process the settlement. Although they # authorize through Secure Acceptance, then later process the settlement. Although they
# are two separate transactions, when taken together the authorization and the settlement # are two separate transactions, when taken together the authorization and the settlement
# constitute one charge. As such, they would be assigned the same reference number. # constitute one charge. As such, they would be assigned the same reference number.
parameters[CS.FIELD_NAMES.REFERENCE_NUMBER] = order.number parameters[CS.FIELD_NAMES.REFERENCE_NUMBER] = basket.id
# Unique identifier associated with this transaction; must be a string. # Unique identifier associated with this transaction; must be a string.
parameters[CS.FIELD_NAMES.TRANSACTION_UUID] = uuid.uuid4().hex parameters[CS.FIELD_NAMES.TRANSACTION_UUID] = uuid.uuid4().hex
...@@ -211,10 +212,10 @@ class Cybersource(BasePaymentProcessor): ...@@ -211,10 +212,10 @@ class Cybersource(BasePaymentProcessor):
parameters[CS.FIELD_NAMES.PAYMENT_METHOD] = CS.PAYMENT_METHOD parameters[CS.FIELD_NAMES.PAYMENT_METHOD] = CS.PAYMENT_METHOD
# ISO currency code representing the currency in which to conduct the transaction. # ISO currency code representing the currency in which to conduct the transaction.
parameters[CS.FIELD_NAMES.CURRENCY] = order.currency parameters[CS.FIELD_NAMES.CURRENCY] = basket.currency
# Total amount to be charged. May contain numeric characters and a decimal point; must be a string. # Total amount to be charged. May contain numeric characters and a decimal point; must be a string.
parameters[CS.FIELD_NAMES.AMOUNT] = unicode(order.total_excl_tax) parameters[CS.FIELD_NAMES.AMOUNT] = unicode(basket.total_excl_tax)
# IETF language tag representing the language to use for customer-facing content. # IETF language tag representing the language to use for customer-facing content.
parameters[CS.FIELD_NAMES.LOCALE] = self.language_code parameters[CS.FIELD_NAMES.LOCALE] = self.language_code
...@@ -222,7 +223,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -222,7 +223,7 @@ class Cybersource(BasePaymentProcessor):
if receipt_page_url: if receipt_page_url:
parameters[CS.FIELD_NAMES.OVERRIDE_CUSTOM_RECEIPT_PAGE] = receipt_page_url parameters[CS.FIELD_NAMES.OVERRIDE_CUSTOM_RECEIPT_PAGE] = receipt_page_url
elif self.receipt_page_url: elif self.receipt_page_url:
parameters[CS.FIELD_NAMES.OVERRIDE_CUSTOM_RECEIPT_PAGE] = self._generate_receipt_url(order) parameters[CS.FIELD_NAMES.OVERRIDE_CUSTOM_RECEIPT_PAGE] = self._generate_receipt_url(basket)
if cancel_page_url: if cancel_page_url:
parameters[CS.FIELD_NAMES.OVERRIDE_CUSTOM_CANCEL_PAGE] = cancel_page_url parameters[CS.FIELD_NAMES.OVERRIDE_CUSTOM_CANCEL_PAGE] = cancel_page_url
...@@ -246,7 +247,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -246,7 +247,7 @@ class Cybersource(BasePaymentProcessor):
# the parameters dictionary before returning it. # the parameters dictionary before returning it.
parameters[CS.FIELD_NAMES.SIGNED_FIELD_NAMES] = CS.SEPARATOR.join(parameters.keys()) parameters[CS.FIELD_NAMES.SIGNED_FIELD_NAMES] = CS.SEPARATOR.join(parameters.keys())
logger.info(u"Generated unsigned CyberSource transaction parameters for order [%s]", order.number) logger.info(u"Generated unsigned CyberSource transaction parameters for basket [%s]", basket.id)
return parameters return parameters
...@@ -263,7 +264,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -263,7 +264,7 @@ class Cybersource(BasePaymentProcessor):
""" """
return "{base_url}?payment-order-num={order_number}".format( return "{base_url}?payment-order-num={order_number}".format(
base_url=self.receipt_page_url, order_number=order.number base_url=self.receipt_page_url, order_number=order.id
) )
def _generate_signature(self, parameters): def _generate_signature(self, parameters):
...@@ -439,12 +440,12 @@ class SingleSeatCybersource(Cybersource): ...@@ -439,12 +440,12 @@ class SingleSeatCybersource(Cybersource):
if line and line.product.get_product_class().name == 'Seat': if line and line.product.get_product_class().name == 'Seat':
course_key = line.product.attribute_values.get(attribute__name="course_key").value course_key = line.product.attribute_values.get(attribute__name="course_key").value
return "{base_url}{course_key}/?payment-order-num={order_number}".format( return "{base_url}{course_key}/?payment-order-num={order_number}".format(
base_url=self.receipt_page_url, course_key=course_key, order_number=order.number base_url=self.receipt_page_url, course_key=course_key, order_number=order.id
) )
else: else:
msg = ( msg = (
u'Cannot construct a receipt URL for order [{order_number}]. Receipt page only supports Seat products.' u'Cannot construct a receipt URL for order [{order_number}]. Receipt page only supports Seat products.'
.format(order_number=order.number) .format(order_number=order.id)
) )
logger.error(msg) logger.error(msg)
raise UnsupportedProductError(msg) raise UnsupportedProductError(msg)
"""Serializers for Payment data."""
# pylint: disable=abstract-method
from rest_framework import serializers
class TransactionSerializer(serializers.Serializer):
"""Serializes a transaction. """
txn_type = serializers.CharField(max_length=128)
amount = serializers.DecimalField(decimal_places=2, max_digits=12)
reference = serializers.CharField(max_length=128)
status = serializers.CharField(max_length=128)
date_created = serializers.DateTimeField()
class SourceTypeSerializer(serializers.Serializer):
"""Serializes the payment source type. """
name = serializers.CharField(max_length=128)
code = serializers.CharField(max_length=128)
class SourceSerializer(serializers.Serializer):
"""Serializes a payment source. """
source_type = SourceTypeSerializer()
transactions = TransactionSerializer(many=True)
currency = serializers.CharField(max_length=12)
amount_allocated = serializers.DecimalField(decimal_places=2, max_digits=12)
amount_debited = serializers.DecimalField(decimal_places=2, max_digits=12)
amount_refunded = serializers.DecimalField(decimal_places=2, max_digits=12)
reference = serializers.CharField(max_length=128)
label = serializers.CharField(max_length=128)
...@@ -27,7 +27,7 @@ User = get_user_model() ...@@ -27,7 +27,7 @@ User = get_user_model()
class PaymentProcessorTestCase(TestCase): class PaymentProcessorTestCase(TestCase):
"""Base test class for payment processor classes.""" """Base test class for payment processor classes."""
ORDER_NUMBER = '001' ORDER_NUMBER = '1'
def setUp(self): def setUp(self):
# Override all loggers, suppressing logging calls of severity CRITICAL and below # Override all loggers, suppressing logging calls of severity CRITICAL and below
...@@ -77,11 +77,11 @@ class PaymentProcessorTestCase(TestCase): ...@@ -77,11 +77,11 @@ class PaymentProcessorTestCase(TestCase):
value_text='pollos/hermanosX/2015' value_text='pollos/hermanosX/2015'
) )
basket = factories.create_basket(empty=True) self.basket = factories.create_basket(empty=True)
basket.add_product(pollos_hermanos, 1) self.basket.add_product(pollos_hermanos, 1)
self.order = factories.create_order( self.order = factories.create_order(
number=self.ORDER_NUMBER, basket=basket, user=user, status=ORDER.BEING_PROCESSED number=self.ORDER_NUMBER, basket=self.basket, user=user, status=ORDER.BEING_PROCESSED
) )
# the processor will pass through a string representation of this # the processor will pass through a string representation of this
self.order_total = unicode(self.order.total_excl_tax) self.order_total = unicode(self.order.total_excl_tax)
...@@ -146,12 +146,12 @@ class CybersourceParameterGenerationTests(CybersourceTests): ...@@ -146,12 +146,12 @@ class CybersourceParameterGenerationTests(CybersourceTests):
def test_transaction_parameter_generation(self): def test_transaction_parameter_generation(self):
"""Test that transaction parameter generation produces the correct output for a test order.""" """Test that transaction parameter generation produces the correct output for a test order."""
self._assert_order_parameters(self.order) self._assert_order_parameters(self.basket)
def test_override_receipt_and_cancel_pages(self): def test_override_receipt_and_cancel_pages(self):
"""Test that receipt and cancel page override parameters are included when necessary.""" """Test that receipt and cancel page override parameters are included when necessary."""
self._assert_order_parameters( self._assert_order_parameters(
self.order, self.basket,
receipt_page_url=self.RECEIPT_PAGE_URL, receipt_page_url=self.RECEIPT_PAGE_URL,
cancel_page_url=self.CANCEL_PAGE_URL cancel_page_url=self.CANCEL_PAGE_URL
) )
...@@ -159,7 +159,7 @@ class CybersourceParameterGenerationTests(CybersourceTests): ...@@ -159,7 +159,7 @@ class CybersourceParameterGenerationTests(CybersourceTests):
def test_merchant_defined_data(self): def test_merchant_defined_data(self):
"""Test that merchant-defined data parameters are included when necessary.""" """Test that merchant-defined data parameters are included when necessary."""
self._assert_order_parameters( self._assert_order_parameters(
self.order, self.basket,
merchant_defined_data=self.MERCHANT_DEFINED_DATA merchant_defined_data=self.MERCHANT_DEFINED_DATA
) )
...@@ -169,7 +169,7 @@ class CybersourceParameterGenerationTests(CybersourceTests): ...@@ -169,7 +169,7 @@ class CybersourceParameterGenerationTests(CybersourceTests):
self.product_class.name = 'Not A Seat' self.product_class.name = 'Not A Seat'
self.product_class.save() self.product_class.save()
self._assert_order_parameters( self._assert_order_parameters(
self.order self.basket
) )
@raises(ExcessiveMerchantDefinedData) @raises(ExcessiveMerchantDefinedData)
...@@ -178,9 +178,9 @@ class CybersourceParameterGenerationTests(CybersourceTests): ...@@ -178,9 +178,9 @@ class CybersourceParameterGenerationTests(CybersourceTests):
# Generate a list of strings with a number of elements exceeding the maximum number # Generate a list of strings with a number of elements exceeding the maximum number
# of optional fields allowed by CyberSource # of optional fields allowed by CyberSource
excessive_data = [unicode(i) for i in xrange(CS.MAX_OPTIONAL_FIELDS + 1)] excessive_data = [unicode(i) for i in xrange(CS.MAX_OPTIONAL_FIELDS + 1)]
SingleSeatCybersource().get_transaction_parameters(self.order, merchant_defined_data=excessive_data) SingleSeatCybersource().get_transaction_parameters(self.basket, merchant_defined_data=excessive_data)
def _assert_order_parameters(self, order, receipt_page_url=None, cancel_page_url=None, merchant_defined_data=None): def _assert_order_parameters(self, basket, receipt_page_url=None, cancel_page_url=None, merchant_defined_data=None):
"""Verify that returned transaction parameters match expectations.""" """Verify that returned transaction parameters match expectations."""
expected_receipt_page_url = receipt_page_url expected_receipt_page_url = receipt_page_url
...@@ -188,14 +188,14 @@ class CybersourceParameterGenerationTests(CybersourceTests): ...@@ -188,14 +188,14 @@ class CybersourceParameterGenerationTests(CybersourceTests):
expected_receipt_page_url = '{receipt_url}{course_key}/?payment-order-num={order_number}'.format( expected_receipt_page_url = '{receipt_url}{course_key}/?payment-order-num={order_number}'.format(
receipt_url=settings.PAYMENT_PROCESSOR_CONFIG['cybersource']['receipt_page_url'], receipt_url=settings.PAYMENT_PROCESSOR_CONFIG['cybersource']['receipt_page_url'],
course_key=self.attribute_value.value, course_key=self.attribute_value.value,
order_number=self.order.number order_number=self.basket.id
) )
if not cancel_page_url: if not cancel_page_url:
cancel_page_url = settings.PAYMENT_PROCESSOR_CONFIG['cybersource']['cancel_page_url'] cancel_page_url = settings.PAYMENT_PROCESSOR_CONFIG['cybersource']['cancel_page_url']
returned_parameters = SingleSeatCybersource().get_transaction_parameters( returned_parameters = SingleSeatCybersource().get_transaction_parameters(
order, basket,
receipt_page_url=receipt_page_url, receipt_page_url=receipt_page_url,
cancel_page_url=cancel_page_url, cancel_page_url=cancel_page_url,
merchant_defined_data=merchant_defined_data merchant_defined_data=merchant_defined_data
...@@ -205,12 +205,12 @@ class CybersourceParameterGenerationTests(CybersourceTests): ...@@ -205,12 +205,12 @@ class CybersourceParameterGenerationTests(CybersourceTests):
expected_parameters = OrderedDict([ expected_parameters = OrderedDict([
(CS.FIELD_NAMES.ACCESS_KEY, cybersource_settings['access_key']), (CS.FIELD_NAMES.ACCESS_KEY, cybersource_settings['access_key']),
(CS.FIELD_NAMES.PROFILE_ID, cybersource_settings['profile_id']), (CS.FIELD_NAMES.PROFILE_ID, cybersource_settings['profile_id']),
(CS.FIELD_NAMES.REFERENCE_NUMBER, order.number), (CS.FIELD_NAMES.REFERENCE_NUMBER, basket.id),
(CS.FIELD_NAMES.TRANSACTION_UUID, self.UUID_HEX), (CS.FIELD_NAMES.TRANSACTION_UUID, self.UUID_HEX),
(CS.FIELD_NAMES.TRANSACTION_TYPE, CS.TRANSACTION_TYPE), (CS.FIELD_NAMES.TRANSACTION_TYPE, CS.TRANSACTION_TYPE),
(CS.FIELD_NAMES.PAYMENT_METHOD, CS.PAYMENT_METHOD), (CS.FIELD_NAMES.PAYMENT_METHOD, CS.PAYMENT_METHOD),
(CS.FIELD_NAMES.CURRENCY, order.currency), (CS.FIELD_NAMES.CURRENCY, basket.currency),
(CS.FIELD_NAMES.AMOUNT, unicode(order.total_excl_tax)), (CS.FIELD_NAMES.AMOUNT, unicode(basket.total_excl_tax)),
(CS.FIELD_NAMES.LOCALE, getattr(settings, 'LANGUAGE_CODE')), (CS.FIELD_NAMES.LOCALE, getattr(settings, 'LANGUAGE_CODE')),
]) ])
......
...@@ -29,7 +29,7 @@ class CybersourceResponseView(View, OrderPlacementMixin, FulfillmentMixin): ...@@ -29,7 +29,7 @@ class CybersourceResponseView(View, OrderPlacementMixin, FulfillmentMixin):
# register the money in Oscar # register the money in Oscar
self._register_payment(order, payment_processor.NAME) self._register_payment(order, payment_processor.NAME)
# fulfill the order # fulfill the order
self._fulfill_order(order) self.fulfill_order(order)
# It doesn't matter how we respond to the payment processor if the # It doesn't matter how we respond to the payment processor if the
# payment failed. # payment failed.
......
from django.conf.urls import patterns, url, include from django.conf.urls import patterns, url, include
from ecommerce.extensions.app import application from ecommerce.extensions.app import application
from ecommerce.extensions.api.app import application as api
from ecommerce.extensions.payment.app import application as payment from ecommerce.extensions.payment.app import application as payment
# Uncomment the next two lines to enable the admin
# from django.contrib import admin
# admin.autodiscover()
urlpatterns = patterns( urlpatterns = patterns(
'', '',
# Uncomment the next line to enable the admin
# url(r'^admin/', include(admin.site.urls)),
# Oscar URLs # Oscar URLs
url(r'^i18n/', include('django.conf.urls.i18n')), url(r'^i18n/', include('django.conf.urls.i18n')),
url(r'^api/v1/', include(api.urls)), url(r'^api/', include('ecommerce.extensions.api.urls', namespace='api')),
url(r'^payment/', include(payment.urls)), url(r'^payment/', include(payment.urls)),
# This is only here to ensure the login page works for integration tests. # This is only here to ensure the login page works for integration tests.
......
...@@ -110,11 +110,6 @@ OSCAR_DEFAULT_CURRENCY = 'USD' ...@@ -110,11 +110,6 @@ OSCAR_DEFAULT_CURRENCY = 'USD'
# END ORDER PROCESSING # END ORDER PROCESSING
# RATE LIMITING
ORDERS_ENDPOINT_RATE_LIMIT = '40/minute'
# END RATE LIMITING
# PAYMENT PROCESSING # PAYMENT PROCESSING
PAYMENT_PROCESSORS = ( PAYMENT_PROCESSORS = (
'ecommerce.extensions.payment.processors.SingleSeatCybersource', 'ecommerce.extensions.payment.processors.SingleSeatCybersource',
...@@ -125,7 +120,7 @@ PAYMENT_PROCESSOR_CONFIG = { ...@@ -125,7 +120,7 @@ PAYMENT_PROCESSOR_CONFIG = {
'profile_id': 'set-me-please', 'profile_id': 'set-me-please',
'access_key': 'set-me-please', 'access_key': 'set-me-please',
'secret_key': 'set-me-please', 'secret_key': 'set-me-please',
'pay_endpoint': 'https://replace-me/', 'payment_page_url': 'https://replace-me/',
# TODO: XCOM-202 must be completed before any other receipt page is used. # TODO: XCOM-202 must be completed before any other receipt page is used.
# By design this specific receipt page is expected. # By design this specific receipt page is expected.
'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/', 'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/',
......
...@@ -388,7 +388,13 @@ REST_FRAMEWORK = { ...@@ -388,7 +388,13 @@ REST_FRAMEWORK = {
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
), ),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20 'PAGE_SIZE': 20,
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.UserRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'user': '40/minute',
},
} }
# END DJANGO REST FRAMEWORK # END DJANGO REST FRAMEWORK
......
...@@ -105,7 +105,7 @@ PAYMENT_PROCESSOR_CONFIG = { ...@@ -105,7 +105,7 @@ PAYMENT_PROCESSOR_CONFIG = {
'profile_id': 'fake-profile-id', 'profile_id': 'fake-profile-id',
'access_key': 'fake-access-key', 'access_key': 'fake-access-key',
'secret_key': 'fake-secret-key', 'secret_key': 'fake-secret-key',
'pay_endpoint': 'https://replace-me/', 'payment_page_url': 'https://replace-me/',
# TODO: XCOM-202 must be completed before any other receipt page is used. # TODO: XCOM-202 must be completed before any other receipt page is used.
# By design this specific receipt page is expected. # By design this specific receipt page is expected.
'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/', 'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/',
......
...@@ -91,7 +91,7 @@ PAYMENT_PROCESSOR_CONFIG = { ...@@ -91,7 +91,7 @@ PAYMENT_PROCESSOR_CONFIG = {
'profile_id': 'fake-profile-id', 'profile_id': 'fake-profile-id',
'access_key': 'fake-access-key', 'access_key': 'fake-access-key',
'secret_key': 'fake-secret-key', 'secret_key': 'fake-secret-key',
'pay_endpoint': 'https://replace-me/', 'payment_page_url': 'https://replace-me/',
# TODO: XCOM-202 must be completed before any other receipt page is used. # TODO: XCOM-202 must be completed before any other receipt page is used.
# By design this specific receipt page is expected. # By design this specific receipt page is expected.
'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/', 'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/',
......
""" # -*- coding: utf-8 -*-
Broadly-useful mixins for use in automated tests. """Broadly-useful mixins for use in automated tests."""
""" import json
from django.conf import settings from decimal import Decimal as D
import jwt import jwt
from django.conf import settings
from django.core.cache import cache
from django.core.urlresolvers import reverse
from oscar.test import factories from oscar.test import factories
from oscar.core.loading import get_model
from ecommerce.extensions.api.constants import APIConstants as AC
from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin
Basket = get_model('basket', 'Basket')
ShippingEventType = get_model('order', 'ShippingEventType')
Order = get_model('order', 'Order')
class UserMixin(object): class UserMixin(object):
""" Provides utility methods for creating and authenticating users in test cases. """ """Provides utility methods for creating and authenticating users in test cases."""
password = 'test' password = 'test'
def create_user(self, **kwargs): def create_user(self, **kwargs):
""" Create a user, with overrideable defaults. """ """Create a user, with overrideable defaults."""
return factories.UserFactory(password=self.password, **kwargs) return factories.UserFactory(password=self.password, **kwargs)
def generate_jwt_token_header(self, user, secret=None): def generate_jwt_token_header(self, user, secret=None):
""" Generate a valid JWT token header for authenticated requests. """ """Generate a valid JWT token header for authenticated requests."""
secret = secret or getattr(settings, 'JWT_AUTH')['JWT_SECRET_KEY'] secret = secret or getattr(settings, 'JWT_AUTH')['JWT_SECRET_KEY']
payload = { payload = {
'username': user.username, 'username': user.username,
'email': user.email, 'email': user.email,
} }
return "JWT {token}".format(token=jwt.encode(payload, secret)) return "JWT {token}".format(token=jwt.encode(payload, secret))
class ThrottlingMixin(object):
"""Provides utility methods for test cases validating the behavior of rate-limited endpoints."""
def setUp(self):
super(ThrottlingMixin, self).setUp()
# Throttling for tests relies on the cache. To get around throttling, simply clear the cache.
self.addCleanup(cache.clear)
class BasketCreationMixin(object):
"""Provides utility methods for creating baskets in test cases."""
PATH = reverse('api:v2:baskets:create')
SHIPPING_EVENT_NAME = FulfillmentMixin.SHIPPING_EVENT_NAME
JWT_SECRET_KEY = getattr(settings, 'JWT_AUTH')['JWT_SECRET_KEY']
FREE_SKU = u'𝑭𝑹𝑬𝑬-𝑷𝑹𝑶𝑫𝑼𝑪𝑻'
USER_DATA = {
'username': 'sgoodman',
'email': 'saul@bettercallsaul.com',
}
def setUp(self):
super(BasketCreationMixin, self).setUp()
product_class = factories.ProductClassFactory(
name=u'𝑨𝒖𝒕𝒐𝒎𝒐𝒃𝒊𝒍𝒆',
requires_shipping=False,
track_stock=False
)
self.base_product = factories.ProductFactory(
structure='parent',
title=u'𝑳𝒂𝒎𝒃𝒐𝒓𝒈𝒉𝒊𝒏𝒊 𝑮𝒂𝒍𝒍𝒂𝒓𝒅𝒐',
product_class=product_class,
stockrecords=None,
)
self.free_product = factories.ProductFactory(
structure='child',
parent=self.base_product,
title=u'𝑪𝒂𝒓𝒅𝒃𝒐𝒂𝒓𝒅 𝑪𝒖𝒕𝒐𝒖𝒕',
stockrecords__partner_sku=self.FREE_SKU,
stockrecords__price_excl_tax=D('0.00'),
)
def generate_token(self, payload, secret=None):
"""Generate a JWT token with the provided payload."""
secret = secret or self.JWT_SECRET_KEY
token = jwt.encode(payload, secret)
return token
def create_basket(self, skus=None, checkout=None, payment_processor_name=None, auth=True, token=None):
"""Issue a POST request to the basket creation endpoint."""
request_data = {}
if skus:
request_data[AC.KEYS.PRODUCTS] = []
for sku in skus:
request_data[AC.KEYS.PRODUCTS].append({AC.KEYS.SKU: sku})
if checkout:
request_data[AC.KEYS.CHECKOUT] = checkout
if payment_processor_name:
request_data[AC.KEYS.PAYMENT_PROCESSOR_NAME] = payment_processor_name
if auth:
token = token or self.generate_token(self.USER_DATA)
response = self.client.post(
self.PATH,
data=json.dumps(request_data),
content_type='application/json',
HTTP_AUTHORIZATION='JWT ' + token
)
else:
response = self.client.post(
self.PATH,
data=json.dumps(request_data),
content_type='application/json'
)
return response
def assert_successful_basket_creation(
self, skus=None, checkout=None, payment_processor_name=None, requires_payment=False
):
"""Verify that basket creation succeeded."""
# Ideally, we'd use Oscar's ShippingEventTypeFactory here, but it's not exposed/public.
ShippingEventType.objects.create(code='shipped', name=self.SHIPPING_EVENT_NAME)
response = self.create_basket(skus=skus, checkout=checkout, payment_processor_name=payment_processor_name)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], Basket.objects.get().id)
if checkout:
if requires_payment:
self.assertIsNone(response.data[AC.KEYS.ORDER])
self.assertIsNotNone(response.data[AC.KEYS.PAYMENT_DATA][AC.KEYS.PAYMENT_PROCESSOR_NAME])
self.assertIsNotNone(response.data[AC.KEYS.PAYMENT_DATA][AC.KEYS.PAYMENT_FORM_DATA])
self.assertIsNotNone(response.data[AC.KEYS.PAYMENT_DATA][AC.KEYS.PAYMENT_PAGE_URL])
else:
self.assertEqual(response.data[AC.KEYS.ORDER][AC.KEYS.ORDER_NUMBER], Order.objects.get().number)
self.assertIsNone(response.data[AC.KEYS.PAYMENT_DATA])
else:
self.assertIsNone(response.data[AC.KEYS.ORDER])
self.assertIsNone(response.data[AC.KEYS.PAYMENT_DATA])
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