diff --git a/lms/djangoapps/commerce/api.py b/lms/djangoapps/commerce/api.py index 8f943f1fca..17ac85b2ee 100644 --- a/lms/djangoapps/commerce/api.py +++ b/lms/djangoapps/commerce/api.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 0651312764..9976ff8f10 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 9363521ddc..7ad0d2f149 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -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[0-9]*)/$', 'show_receipt'), + url(r'^receipt/(?P[-\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 diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 39633c01c6..b2f3a56b1d 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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.