Merge pull request #7553 from edx/sanchez/support_oscar_receipts
XCOM-128: Allow the receipt page to support E-Commerce Orders.
This commit is contained in:
@@ -42,6 +42,26 @@ class EcommerceAPI(object):
|
||||
}
|
||||
return jwt.encode(data, self.key)
|
||||
|
||||
def get_order(self, user, order_number):
|
||||
"""
|
||||
Retrieve a paid order.
|
||||
|
||||
Arguments
|
||||
user -- User associated with the requested order.
|
||||
order_number -- The unique identifier for the order.
|
||||
|
||||
Returns a tuple with the order number, order status, API response data.
|
||||
"""
|
||||
def get():
|
||||
"""Internal service call to retrieve an order. """
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
url = '{base_url}/orders/{order_number}/'.format(base_url=self.url, order_number=order_number)
|
||||
return requests.get(url, headers=headers, timeout=self.timeout)
|
||||
return self._call_ecommerce_service(get)
|
||||
|
||||
def create_order(self, user, sku):
|
||||
"""
|
||||
Create a new order.
|
||||
@@ -52,21 +72,35 @@ class EcommerceAPI(object):
|
||||
|
||||
Returns a tuple with the order number, order status, API response data.
|
||||
"""
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
def create():
|
||||
"""Internal service call to create an order. """
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
url = '{}/orders/'.format(self.url)
|
||||
return requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout)
|
||||
return self._call_ecommerce_service(create)
|
||||
|
||||
url = '{}/orders/'.format(self.url)
|
||||
@staticmethod
|
||||
def _call_ecommerce_service(call):
|
||||
"""
|
||||
Makes a call to the E-Commerce Service. There are a number of common errors that could occur across any
|
||||
request to the E-Commerce Service that this helper method can wrap each call with. This method helps ensure
|
||||
calls to the E-Commerce Service will conform to the same output.
|
||||
|
||||
Arguments
|
||||
call -- A callable function that makes a request to the E-Commerce Service.
|
||||
|
||||
Returns a tuple with the order number, order status, API response data.
|
||||
"""
|
||||
try:
|
||||
response = requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout)
|
||||
response = call()
|
||||
data = response.json()
|
||||
except Timeout:
|
||||
msg = 'E-Commerce API request timed out.'
|
||||
log.error(msg)
|
||||
raise TimeoutError(msg)
|
||||
|
||||
except ValueError:
|
||||
msg = 'E-Commerce API response is not valid JSON.'
|
||||
log.exception(msg)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Tests for Shopping Cart views
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import copy
|
||||
import mock
|
||||
import pytz
|
||||
from urlparse import urlparse
|
||||
from decimal import Decimal
|
||||
@@ -27,6 +29,9 @@ import ddt
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.constants import OrderStatus
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from student.roles import CourseSalesAdminRole
|
||||
from util.date_utils import get_default_time_display
|
||||
from util.testing import UrlResetMixin
|
||||
@@ -866,6 +871,106 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
'line_desc': 'Honor Code Certificate for course Test Course'
|
||||
})
|
||||
|
||||
@ddt.data(0, 1, 2)
|
||||
@override_settings(
|
||||
ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY
|
||||
)
|
||||
def test_show_ecom_receipt_json(self, num_items):
|
||||
# set up the get request to return an order with X number of line items.
|
||||
|
||||
# Log in the student. Use a false order ID for the E-Commerce Application.
|
||||
self.login_user()
|
||||
url = reverse('shoppingcart.views.show_receipt', args=['EDX-100042'])
|
||||
with self.mock_get_order(num_items=num_items):
|
||||
resp = self.client.get(url, HTTP_ACCEPT="application/json")
|
||||
|
||||
# Should have gotten a successful response
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Parse the response as JSON and check the contents
|
||||
json_resp = json.loads(resp.content)
|
||||
self.assertEqual(json_resp.get('currency'), self.mock_get_order.ORDER['currency'])
|
||||
self.assertEqual(json_resp.get('purchase_datetime'), 'Apr 07, 2015 at 17:59 UTC')
|
||||
self.assertEqual(json_resp.get('total_cost'), self.mock_get_order.ORDER['total_excl_tax'])
|
||||
self.assertEqual(json_resp.get('status'), self.mock_get_order.ORDER['status'])
|
||||
self.assertEqual(json_resp.get('billed_to'), {
|
||||
'first_name': self.mock_get_order.ORDER['billing_address']['first_name'],
|
||||
'last_name': self.mock_get_order.ORDER['billing_address']['last_name'],
|
||||
'street1': self.mock_get_order.ORDER['billing_address']['line1'],
|
||||
'street2': self.mock_get_order.ORDER['billing_address']['line2'],
|
||||
'city': self.mock_get_order.ORDER['billing_address']['line4'],
|
||||
'state': self.mock_get_order.ORDER['billing_address']['state'],
|
||||
'postal_code': self.mock_get_order.ORDER['billing_address']['postcode'],
|
||||
'country': self.mock_get_order.ORDER['billing_address']['country']['display_name']
|
||||
})
|
||||
|
||||
self.assertEqual(len(json_resp.get('items')), num_items)
|
||||
for item in json_resp.get('items'):
|
||||
self.assertEqual(item, {
|
||||
'unit_cost': self.mock_get_order.LINE['unit_price_excl_tax'],
|
||||
'quantity': self.mock_get_order.LINE['quantity'],
|
||||
'line_cost': self.mock_get_order.LINE['line_price_excl_tax'],
|
||||
'line_desc': self.mock_get_order.LINE['description']
|
||||
})
|
||||
|
||||
class mock_get_order(object): # pylint: disable=invalid-name
|
||||
"""Mocks calls to EcommerceAPI.get_order. """
|
||||
patch = None
|
||||
|
||||
ORDER = copy.deepcopy(EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY)
|
||||
ORDER['total_excl_tax'] = 40.0
|
||||
ORDER['currency'] = 'USD'
|
||||
ORDER['sources'] = [{'transactions': [
|
||||
{'date_created': '2015-04-07 17:59:06.274587+00:00'},
|
||||
{'date_created': '2015-04-08 13:33:06.150000+00:00'},
|
||||
{'date_created': '2015-04-09 10:45:06.200000+00:00'},
|
||||
]}]
|
||||
ORDER['billing_address'] = {
|
||||
'first_name': 'Philip',
|
||||
'last_name': 'Fry',
|
||||
'line1': 'Robot Arms Apts',
|
||||
'line2': '22 Robot Street',
|
||||
'line4': 'New New York',
|
||||
'state': 'NY',
|
||||
'postcode': '11201',
|
||||
'country': {
|
||||
'display_name': 'United States',
|
||||
},
|
||||
}
|
||||
|
||||
LINE = {
|
||||
"title": "Honor Code Certificate for course Test Course",
|
||||
"description": "Honor Code Certificate for course Test Course",
|
||||
"status": "Paid",
|
||||
"line_price_excl_tax": 40.0,
|
||||
"quantity": 1,
|
||||
"unit_price_excl_tax": 40.0
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
result = copy.deepcopy(self.ORDER)
|
||||
result['lines'] = [copy.deepcopy(self.LINE) for _ in xrange(kwargs['num_items'])]
|
||||
default_kwargs = {
|
||||
'return_value': (
|
||||
EcommerceApiTestMixin.ORDER_NUMBER,
|
||||
OrderStatus.COMPLETE,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
default_kwargs.update(kwargs)
|
||||
|
||||
self.patch = mock.patch.object(EcommerceAPI, 'get_order', mock.Mock(**default_kwargs))
|
||||
|
||||
def __enter__(self):
|
||||
self.patch.start()
|
||||
return self.patch.new
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
|
||||
self.patch.stop()
|
||||
|
||||
def test_show_receipt_json_multiple_items(self):
|
||||
# Two different item types
|
||||
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
|
||||
|
||||
@@ -5,7 +5,7 @@ urlpatterns = patterns(
|
||||
'shoppingcart.views',
|
||||
|
||||
url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here
|
||||
url(r'^receipt/(?P<ordernum>[0-9]*)/$', 'show_receipt'),
|
||||
url(r'^receipt/(?P<ordernum>[-\w]+)/$', 'show_receipt'),
|
||||
url(r'^donation/$', 'donate', name='donation'),
|
||||
url(r'^csv_report/$', 'csv_report', name='payment_csv_report'),
|
||||
# These following URLs are only valid if the ENABLE_SHOPPING_CART feature flag is set
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import datetime
|
||||
import decimal
|
||||
import dateutil
|
||||
import pytz
|
||||
from ipware.ip import get_ip
|
||||
from django.db.models import Q
|
||||
@@ -12,6 +13,9 @@ from django.http import (
|
||||
HttpResponseBadRequest, HttpResponseForbidden, Http404
|
||||
)
|
||||
from django.utils.translation import ugettext as _
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.exceptions import InvalidConfigurationError, ApiError
|
||||
from commerce.http import InternalRequestErrorResponse
|
||||
from course_modes.models import CourseMode
|
||||
from util.json_request import JsonResponse
|
||||
from django.views.decorators.http import require_POST, require_http_methods
|
||||
@@ -820,20 +824,91 @@ def show_receipt(request, ordernum):
|
||||
Displays a receipt for a particular order.
|
||||
404 if order is not yet purchased or request.user != order.user
|
||||
"""
|
||||
is_json_request = 'application/json' in request.META.get('HTTP_ACCEPT', "")
|
||||
try:
|
||||
order = Order.objects.get(id=ordernum)
|
||||
except Order.DoesNotExist:
|
||||
raise Http404('Order not found!')
|
||||
except (Order.DoesNotExist, ValueError):
|
||||
if is_json_request:
|
||||
return _get_external_order(request, ordernum)
|
||||
else:
|
||||
raise Http404('Order not found!')
|
||||
|
||||
if order.user != request.user or order.status not in ['purchased', 'refunded']:
|
||||
raise Http404('Order not found!')
|
||||
|
||||
if 'application/json' in request.META.get('HTTP_ACCEPT', ""):
|
||||
if is_json_request:
|
||||
return _show_receipt_json(order)
|
||||
else:
|
||||
return _show_receipt_html(request, order)
|
||||
|
||||
|
||||
def _get_external_order(request, order_number):
|
||||
"""Get the order context from the external E-Commerce Service.
|
||||
|
||||
Get information about an order. This function makes a request to the E-Commerce Service to see if there is
|
||||
order information that can be used to render a receipt for the user.
|
||||
|
||||
Args:
|
||||
request (Request): The request for the the receipt.
|
||||
order_number (str) : The order number.
|
||||
|
||||
Returns:
|
||||
dict: A serializable dictionary of the receipt page context based on an order returned from the E-Commerce
|
||||
Service.
|
||||
|
||||
"""
|
||||
try:
|
||||
api = EcommerceAPI()
|
||||
order_number, order_status, order_data = api.get_order(request.user, order_number)
|
||||
billing = order_data.get('billing_address', {})
|
||||
country = billing.get('country', {})
|
||||
|
||||
# In order to get the date this order was paid, we need to check for payment sources, and associated
|
||||
# transactions.
|
||||
payment_dates = []
|
||||
for source in order_data.get('sources', []):
|
||||
for transaction in source.get('transactions', []):
|
||||
payment_dates.append(dateutil.parser.parse(transaction['date_created']))
|
||||
payment_date = sorted(payment_dates, reverse=True).pop()
|
||||
|
||||
order_info = {
|
||||
'orderNum': order_number,
|
||||
'currency': order_data['currency'],
|
||||
'status': order_status,
|
||||
'purchase_datetime': get_default_time_display(payment_date),
|
||||
'total_cost': order_data['total_excl_tax'],
|
||||
'billed_to': {
|
||||
'first_name': billing.get('first_name', ''),
|
||||
'last_name': billing.get('last_name', ''),
|
||||
'street1': billing.get('line1', ''),
|
||||
'street2': billing.get('line2', ''),
|
||||
'city': billing.get('line4', ''), # 'line4' is the City, from the E-Commerce Service
|
||||
'state': billing.get('state', ''),
|
||||
'postal_code': billing.get('postcode', ''),
|
||||
'country': country.get('display_name', ''),
|
||||
},
|
||||
'items': [
|
||||
{
|
||||
'quantity': item['quantity'],
|
||||
'unit_cost': item['unit_price_excl_tax'],
|
||||
'line_cost': item['line_price_excl_tax'],
|
||||
'line_desc': item['description']
|
||||
}
|
||||
for item in order_data['lines']
|
||||
]
|
||||
}
|
||||
return JsonResponse(order_info)
|
||||
except InvalidConfigurationError:
|
||||
msg = u"E-Commerce API not setup. Cannot request Order [{order_number}] for User [{user_id}] ".format(
|
||||
user_id=request.user.id, order_number=order_number
|
||||
)
|
||||
log.debug(msg)
|
||||
return JsonResponse(status=500, object={'error_message': msg})
|
||||
except ApiError as err:
|
||||
# The API will handle logging of the error.
|
||||
return InternalRequestErrorResponse(err.message)
|
||||
|
||||
|
||||
def _show_receipt_json(order):
|
||||
"""Render the receipt page as JSON.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user