Commit 16692741 by Stephen Sanchez

:Allow the receipt page to support Oscar Orders.

parent 2c080af6
...@@ -42,6 +42,26 @@ class EcommerceAPI(object): ...@@ -42,6 +42,26 @@ class EcommerceAPI(object):
} }
return jwt.encode(data, self.key) 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): def create_order(self, user, sku):
""" """
Create a new order. Create a new order.
...@@ -52,21 +72,35 @@ class EcommerceAPI(object): ...@@ -52,21 +72,35 @@ class EcommerceAPI(object):
Returns a tuple with the order number, order status, API response data. Returns a tuple with the order number, order status, API response data.
""" """
headers = { def create():
'Content-Type': 'application/json', """Internal service call to create an order. """
'Authorization': 'JWT {}'.format(self._get_jwt(user)) 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)
@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.
url = '{}/orders/'.format(self.url) 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: try:
response = requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout) response = call()
data = response.json() data = response.json()
except Timeout: except Timeout:
msg = 'E-Commerce API request timed out.' msg = 'E-Commerce API request timed out.'
log.error(msg) log.error(msg)
raise TimeoutError(msg) raise TimeoutError(msg)
except ValueError: except ValueError:
msg = 'E-Commerce API response is not valid JSON.' msg = 'E-Commerce API response is not valid JSON.'
log.exception(msg) log.exception(msg)
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
Tests for Shopping Cart views Tests for Shopping Cart views
""" """
from collections import OrderedDict from collections import OrderedDict
import copy
import mock
import pytz import pytz
from urlparse import urlparse from urlparse import urlparse
from decimal import Decimal from decimal import Decimal
...@@ -27,6 +29,9 @@ import ddt ...@@ -27,6 +29,9 @@ import ddt
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory 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 student.roles import CourseSalesAdminRole
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
...@@ -866,6 +871,106 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): ...@@ -866,6 +871,106 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
'line_desc': 'Honor Code Certificate for course Test Course' '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): def test_show_receipt_json_multiple_items(self):
# Two different item types # Two different item types
PaidCourseRegistration.add_to_order(self.cart, self.course_key) PaidCourseRegistration.add_to_order(self.cart, self.course_key)
......
...@@ -5,7 +5,7 @@ urlpatterns = patterns( ...@@ -5,7 +5,7 @@ urlpatterns = patterns(
'shoppingcart.views', 'shoppingcart.views',
url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here 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'^donation/$', 'donate', name='donation'),
url(r'^csv_report/$', 'csv_report', name='payment_csv_report'), 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 # These following URLs are only valid if the ENABLE_SHOPPING_CART feature flag is set
......
import logging import logging
import datetime import datetime
import decimal import decimal
import dateutil
import pytz import pytz
from ipware.ip import get_ip from ipware.ip import get_ip
from django.db.models import Q from django.db.models import Q
...@@ -12,6 +13,9 @@ from django.http import ( ...@@ -12,6 +13,9 @@ from django.http import (
HttpResponseBadRequest, HttpResponseForbidden, Http404 HttpResponseBadRequest, HttpResponseForbidden, Http404
) )
from django.utils.translation import ugettext as _ 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 course_modes.models import CourseMode
from util.json_request import JsonResponse from util.json_request import JsonResponse
from django.views.decorators.http import require_POST, require_http_methods from django.views.decorators.http import require_POST, require_http_methods
...@@ -820,20 +824,91 @@ def show_receipt(request, ordernum): ...@@ -820,20 +824,91 @@ def show_receipt(request, ordernum):
Displays a receipt for a particular order. Displays a receipt for a particular order.
404 if order is not yet purchased or request.user != order.user 404 if order is not yet purchased or request.user != order.user
""" """
is_json_request = 'application/json' in request.META.get('HTTP_ACCEPT', "")
try: try:
order = Order.objects.get(id=ordernum) order = Order.objects.get(id=ordernum)
except Order.DoesNotExist: except (Order.DoesNotExist, ValueError):
raise Http404('Order not found!') 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']: if order.user != request.user or order.status not in ['purchased', 'refunded']:
raise Http404('Order not found!') raise Http404('Order not found!')
if 'application/json' in request.META.get('HTTP_ACCEPT', ""): if is_json_request:
return _show_receipt_json(order) return _show_receipt_json(order)
else: else:
return _show_receipt_html(request, order) 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): def _show_receipt_json(order):
"""Render the receipt page as JSON. """Render the receipt page as JSON.
......
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