Merge pull request #7822 from edx/clintonb/receipt-page-update
Updated Receipt Page for Oscar
This commit is contained in:
@@ -271,6 +271,10 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_user_does_not_match_param(self):
|
||||
"""
|
||||
The view should return status 404 if the enrollment username does not match the username of the user
|
||||
making the request, unless the request is made by a superuser or with a server API key.
|
||||
"""
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug=CourseMode.HONOR,
|
||||
@@ -279,13 +283,19 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
url = reverse('courseenrollment',
|
||||
kwargs={'username': self.other_user.username, "course_id": unicode(self.course.id)})
|
||||
|
||||
resp = self.client.get(url)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Verify that the server still has access to this endpoint.
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.client.logout()
|
||||
resp = self.client.get(url, **{'HTTP_X_EDX_API_KEY': self.API_KEY})
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
response = self.client.get(url, **{'HTTP_X_EDX_API_KEY': self.API_KEY})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# Verify superusers have access to this endpoint
|
||||
superuser = UserFactory.create(password=self.PASSWORD, is_superuser=True)
|
||||
self.client.login(username=superuser.username, password=self.PASSWORD)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_get_course_details(self):
|
||||
CourseModeFactory.create(
|
||||
|
||||
@@ -135,7 +135,9 @@ class EnrollmentView(APIView, ApiKeyPermissionMixIn):
|
||||
"""
|
||||
username = username or request.user.username
|
||||
|
||||
if request.user.username != username and not self.has_api_key_permissions(request):
|
||||
# TODO Implement proper permissions
|
||||
if request.user.username != username and not self.has_api_key_permissions(request) \
|
||||
and not request.user.is_superuser:
|
||||
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
|
||||
# other users, do not let them deduce the existence of an enrollment.
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
""" Commerce app. """
|
||||
from django.conf import settings
|
||||
from ecommerce_api_client.client import EcommerceApiClient
|
||||
|
||||
|
||||
def ecommerce_api_client(user):
|
||||
""" Returns an E-Commerce API client setup with authentication for the specified user. """
|
||||
return EcommerceApiClient(settings.ECOMMERCE_API_URL, settings.ECOMMERCE_API_SIGNING_KEY, user.username,
|
||||
user.email)
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
""" E-Commerce API client """
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
import jwt
|
||||
import requests
|
||||
from requests import Timeout
|
||||
from rest_framework.status import HTTP_200_OK
|
||||
|
||||
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EcommerceAPI(object):
|
||||
""" E-Commerce API client. """
|
||||
|
||||
def __init__(self, url=None, key=None, timeout=None):
|
||||
self.url = url or settings.ECOMMERCE_API_URL
|
||||
self.key = key or settings.ECOMMERCE_API_SIGNING_KEY
|
||||
self.timeout = timeout or getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
|
||||
|
||||
if not (self.url and self.key):
|
||||
raise InvalidConfigurationError('Values for both url and key must be set.')
|
||||
|
||||
# Remove slashes, so that we can properly format URLs regardless of
|
||||
# whether the input includes a trailing slash.
|
||||
self.url = self.url.strip('/')
|
||||
|
||||
def _get_jwt(self, user):
|
||||
"""
|
||||
Returns a JWT object with the specified user's info.
|
||||
|
||||
Raises AttributeError if settings.ECOMMERCE_API_SIGNING_KEY is not set.
|
||||
"""
|
||||
data = {
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
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)
|
||||
|
||||
data = self._call_ecommerce_service(get)
|
||||
return data['number'], data['status'], data
|
||||
|
||||
def get_processors(self, user):
|
||||
"""
|
||||
Retrieve the list of available payment processors.
|
||||
|
||||
Returns a list of strings.
|
||||
"""
|
||||
def get():
|
||||
"""Internal service call to retrieve the processor list. """
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
url = '{base_url}/payment/processors/'.format(base_url=self.url)
|
||||
return requests.get(url, headers=headers, timeout=self.timeout)
|
||||
|
||||
return self._call_ecommerce_service(get)
|
||||
|
||||
def create_basket(self, user, sku, payment_processor=None):
|
||||
"""Create a new basket and immediately trigger checkout.
|
||||
|
||||
Note that while the API supports deferring checkout to a separate step,
|
||||
as well as adding multiple products to the basket, this client does not
|
||||
currently need that capability, so that case is not supported.
|
||||
|
||||
Args:
|
||||
user: the django.auth.User for which the basket should be created.
|
||||
sku: a string containing the SKU of the course seat being ordered.
|
||||
payment_processor: (optional) the name of the payment processor to
|
||||
use for checkout.
|
||||
|
||||
Returns:
|
||||
A dictionary containing {id, order, payment_data}.
|
||||
|
||||
Raises:
|
||||
TimeoutError: the request to the API server timed out.
|
||||
InvalidResponseError: the API server response was not understood.
|
||||
"""
|
||||
def create():
|
||||
"""Internal service call to create a basket. """
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
url = '{}/baskets/'.format(self.url)
|
||||
data = {'products': [{'sku': sku}], 'checkout': True, 'payment_processor_name': payment_processor}
|
||||
return requests.post(url, data=json.dumps(data), 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.
|
||||
|
||||
Arguments
|
||||
call -- A callable function that makes a request to the E-Commerce Service.
|
||||
|
||||
Returns a dict of JSON-decoded API response data.
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
raise InvalidResponseError(msg)
|
||||
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == HTTP_200_OK:
|
||||
return data
|
||||
else:
|
||||
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
|
||||
msg_kwargs = {
|
||||
'status': status_code,
|
||||
'msg': data.get('user_message'),
|
||||
}
|
||||
log.error(msg, msg_kwargs)
|
||||
raise InvalidResponseError(msg % msg_kwargs)
|
||||
@@ -1,21 +1,6 @@
|
||||
""" E-Commerce-related exceptions. """
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
""" Base class for E-Commerce API errors. """
|
||||
pass
|
||||
|
||||
|
||||
class InvalidConfigurationError(ApiError):
|
||||
""" Exception raised when the API is not properly configured (e.g. settings are not set). """
|
||||
pass
|
||||
|
||||
|
||||
class InvalidResponseError(ApiError):
|
||||
class InvalidResponseError(Exception):
|
||||
""" Exception raised when an API response is invalid. """
|
||||
pass
|
||||
|
||||
|
||||
class TimeoutError(ApiError):
|
||||
""" Exception raised when an API requests times out. """
|
||||
pass
|
||||
|
||||
@@ -5,7 +5,7 @@ import httpretty
|
||||
import jwt
|
||||
import mock
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from ecommerce_api_client.client import EcommerceApiClient
|
||||
|
||||
|
||||
class EcommerceApiTestMixin(object):
|
||||
@@ -24,7 +24,7 @@ class EcommerceApiTestMixin(object):
|
||||
ORDER_DATA = {'number': ORDER_NUMBER}
|
||||
ECOMMERCE_API_SUCCESSFUL_BODY = {
|
||||
'id': BASKET_ID,
|
||||
'order': {'number': ORDER_NUMBER}, # never both None.
|
||||
'order': {'number': ORDER_NUMBER}, # never both None.
|
||||
'payment_data': PAYMENT_DATA,
|
||||
}
|
||||
ECOMMERCE_API_SUCCESSFUL_BODY_JSON = json.dumps(ECOMMERCE_API_SUCCESSFUL_BODY) # pylint: disable=invalid-name
|
||||
@@ -61,21 +61,41 @@ class EcommerceApiTestMixin(object):
|
||||
else:
|
||||
response_data['order'] = {'number': self.ORDER_NUMBER}
|
||||
body = json.dumps(response_data)
|
||||
httpretty.register_uri(httpretty.POST, url, status=status, body=body)
|
||||
httpretty.register_uri(httpretty.POST, url, status=status, body=body,
|
||||
adding_headers={'Content-Type': 'application/json'})
|
||||
|
||||
class mock_create_basket(object): # pylint: disable=invalid-name
|
||||
""" Mocks calls to EcommerceAPI.create_basket. """
|
||||
class mock_create_basket(object): # pylint: disable=invalid-name
|
||||
""" Mocks calls to E-Commerce API client basket creation method. """
|
||||
|
||||
patch = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
default_kwargs = {'return_value': EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY}
|
||||
default_kwargs.update(kwargs)
|
||||
self.patch = mock.patch.object(EcommerceAPI, 'create_basket', mock.Mock(**default_kwargs))
|
||||
_mock = mock.Mock()
|
||||
_mock.post = mock.Mock(**default_kwargs)
|
||||
EcommerceApiClient.baskets = _mock
|
||||
self.patch = _mock
|
||||
|
||||
def __enter__(self):
|
||||
self.patch.start()
|
||||
return self.patch.new
|
||||
return self.patch
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
|
||||
self.patch.stop()
|
||||
pass
|
||||
|
||||
class mock_basket_order(object): # pylint: disable=invalid-name
|
||||
""" Mocks calls to E-Commerce API client basket order method. """
|
||||
|
||||
patch = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
_mock = mock.Mock()
|
||||
_mock.order.get = mock.Mock(**kwargs)
|
||||
EcommerceApiClient.baskets = lambda client, basket_id: _mock
|
||||
self.patch = _mock
|
||||
|
||||
def __enter__(self):
|
||||
return self.patch
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
|
||||
pass
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
""" Tests the E-Commerce API module. """
|
||||
|
||||
import json
|
||||
|
||||
from ddt import ddt, data
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.testcases import TestCase
|
||||
from django.test.utils import override_settings
|
||||
import httpretty
|
||||
from requests import Timeout
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@ddt
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
|
||||
""" Tests for the E-Commerce API client. """
|
||||
|
||||
SKU = '1234'
|
||||
|
||||
def setUp(self):
|
||||
super(EcommerceAPITests, self).setUp()
|
||||
self.url = reverse('commerce:baskets')
|
||||
self.user = UserFactory()
|
||||
self.api = EcommerceAPI()
|
||||
|
||||
def test_constructor_url_strip(self):
|
||||
""" Verifies that the URL is stored with trailing slashes removed. """
|
||||
url = 'http://example.com'
|
||||
api = EcommerceAPI(url, 'edx')
|
||||
self.assertEqual(api.url, url)
|
||||
|
||||
api = EcommerceAPI(url + '/', 'edx')
|
||||
self.assertEqual(api.url, url)
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
|
||||
def test_no_settings(self):
|
||||
"""
|
||||
If the settings ECOMMERCE_API_URL and ECOMMERCE_API_SIGNING_KEY are invalid, the constructor should
|
||||
raise a ValueError.
|
||||
"""
|
||||
self.assertRaises(InvalidConfigurationError, EcommerceAPI)
|
||||
|
||||
@httpretty.activate
|
||||
@data(True, False)
|
||||
def test_create_basket(self, is_payment_required):
|
||||
""" Verify the method makes a call to the E-Commerce API with the correct headers and data. """
|
||||
self._mock_ecommerce_api(is_payment_required=is_payment_required)
|
||||
response_data = self.api.create_basket(self.user, self.SKU, self.PROCESSOR)
|
||||
|
||||
# Validate the request sent to the E-Commerce API endpoint.
|
||||
request = httpretty.last_request()
|
||||
self.assertValidBasketRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU, self.PROCESSOR)
|
||||
|
||||
# Validate the data returned by the method
|
||||
self.assertEqual(response_data['id'], self.BASKET_ID)
|
||||
if is_payment_required:
|
||||
self.assertEqual(response_data['order'], None)
|
||||
self.assertEqual(response_data['payment_data'], self.PAYMENT_DATA)
|
||||
else:
|
||||
self.assertEqual(response_data['order'], {"number": self.ORDER_NUMBER})
|
||||
self.assertEqual(response_data['payment_data'], None)
|
||||
|
||||
@httpretty.activate
|
||||
@data(400, 401, 405, 406, 429, 500, 503)
|
||||
def test_create_basket_with_invalid_http_status(self, status):
|
||||
""" If the E-Commerce API returns a non-200 status, the method should raise an InvalidResponseError. """
|
||||
self._mock_ecommerce_api(status=status, body=json.dumps({'user_message': 'FAIL!'}))
|
||||
self.assertRaises(InvalidResponseError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_basket_with_invalid_json(self):
|
||||
""" If the E-Commerce API returns un-parseable data, the method should raise an InvalidResponseError. """
|
||||
self._mock_ecommerce_api(body='TOTALLY NOT JSON!')
|
||||
self.assertRaises(InvalidResponseError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_basket_with_timeout(self):
|
||||
""" If the call to the E-Commerce API times out, the method should raise a TimeoutError. """
|
||||
|
||||
def request_callback(_request, _uri, _headers):
|
||||
""" Simulates API timeout """
|
||||
raise Timeout
|
||||
|
||||
self._mock_ecommerce_api(body=request_callback)
|
||||
|
||||
self.assertRaises(TimeoutError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
|
||||
@@ -5,12 +5,13 @@ from uuid import uuid4
|
||||
|
||||
from ddt import ddt, data
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from ecommerce_api_client import exceptions
|
||||
from commerce.constants import Messages
|
||||
from commerce.exceptions import TimeoutError, ApiError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from course_modes.models import CourseMode
|
||||
from enrollment.api import get_enrollment
|
||||
@@ -19,18 +20,26 @@ from student.tests.factories import UserFactory, CourseModeFactory
|
||||
from student.tests.tests import EnrollmentEventTestMixin
|
||||
|
||||
|
||||
@ddt
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the commerce orders view.
|
||||
"""
|
||||
class UserMixin(object):
|
||||
""" Mixin for tests involving users. """
|
||||
|
||||
def setUp(self):
|
||||
super(UserMixin, self).setUp()
|
||||
self.user = UserFactory()
|
||||
|
||||
def _login(self):
|
||||
""" Log into LMS. """
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
|
||||
|
||||
@ddt
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, UserMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the commerce orders view.
|
||||
"""
|
||||
|
||||
def _post_to_view(self, course_id=None):
|
||||
"""
|
||||
POST to the view being tested.
|
||||
@@ -67,7 +76,6 @@ class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSt
|
||||
def setUp(self):
|
||||
super(BasketsViewTests, self).setUp()
|
||||
self.url = reverse('commerce:baskets')
|
||||
self.user = UserFactory()
|
||||
self._login()
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
@@ -118,7 +126,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSt
|
||||
"""
|
||||
If the call to the E-Commerce API times out, the view should log an error and return an HTTP 503 status.
|
||||
"""
|
||||
with self.mock_create_basket(side_effect=TimeoutError):
|
||||
with self.mock_create_basket(side_effect=exceptions.Timeout):
|
||||
response = self._post_to_view()
|
||||
|
||||
self.assertValidEcommerceInternalRequestErrorResponse(response)
|
||||
@@ -128,7 +136,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSt
|
||||
"""
|
||||
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
|
||||
"""
|
||||
with self.mock_create_basket(side_effect=ApiError):
|
||||
with self.mock_create_basket(side_effect=exceptions.SlumberBaseException):
|
||||
response = self._post_to_view()
|
||||
|
||||
self.assertValidEcommerceInternalRequestErrorResponse(response)
|
||||
@@ -296,6 +304,52 @@ class OrdersViewTests(BasketsViewTests):
|
||||
|
||||
(XCOM-214) remove after release.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(OrdersViewTests, self).setUp()
|
||||
self.url = reverse('commerce:orders')
|
||||
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
class BasketOrderViewTests(UserMixin, EcommerceApiTestMixin, TestCase):
|
||||
""" Tests for the basket order view. """
|
||||
view_name = 'commerce:basket_order'
|
||||
MOCK_ORDER = {'number': 1}
|
||||
path = reverse(view_name, kwargs={'basket_id': 1})
|
||||
|
||||
def setUp(self):
|
||||
super(BasketOrderViewTests, self).setUp()
|
||||
self._login()
|
||||
|
||||
def test_order_found(self):
|
||||
""" If the order is located, the view should pass the data from the API. """
|
||||
|
||||
with self.mock_basket_order(return_value=self.MOCK_ORDER):
|
||||
response = self.client.get(self.path)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
actual = json.loads(response.content)
|
||||
self.assertEqual(actual, self.MOCK_ORDER)
|
||||
|
||||
def test_order_not_found(self):
|
||||
""" If the order is not found, the view should return a 404. """
|
||||
with self.mock_basket_order(side_effect=exceptions.HttpNotFoundError):
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_login_required(self):
|
||||
""" The view should return 403 if the user is not logged in. """
|
||||
self.client.logout()
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class ReceiptViewTests(TestCase):
|
||||
""" Tests for the receipt view. """
|
||||
|
||||
def test_login_required(self):
|
||||
""" The view should redirect to the login page if the user is not logged in. """
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse('commerce:checkout_receipt'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@@ -4,12 +4,15 @@ Defines the URL routes for this app.
|
||||
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
from .views import BasketsView, checkout_cancel
|
||||
from commerce import views
|
||||
|
||||
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^baskets/$', BasketsView.as_view(), name="baskets"),
|
||||
url(r'^checkout/cancel/$', checkout_cancel, name="checkout_cancel"),
|
||||
# (XCOM-214) For backwards compatibility with js clients during intial release
|
||||
url(r'^orders/$', BasketsView.as_view(), name="orders"),
|
||||
url(r'^orders/$', views.BasketsView.as_view(), name="orders"),
|
||||
url(r'^baskets/$', views.BasketsView.as_view(), name="baskets"),
|
||||
url(r'^baskets/{}/order/$'.format(BASKET_ID_PATTERN), views.BasketOrderView.as_view(), name="basket_order"),
|
||||
url(r'^checkout/cancel/$', views.checkout_cancel, name="checkout_cancel"),
|
||||
url(r'^checkout/receipt/$', views.checkout_receipt, name="checkout_receipt"),
|
||||
)
|
||||
|
||||
@@ -2,25 +2,30 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from ecommerce_api_client import exceptions
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce import ecommerce_api_client
|
||||
from commerce.constants import Messages
|
||||
from commerce.exceptions import ApiError, InvalidConfigurationError, InvalidResponseError
|
||||
from commerce.exceptions import InvalidResponseError
|
||||
from commerce.http import DetailResponse, InternalRequestErrorResponse
|
||||
from course_modes.models import CourseMode
|
||||
from courseware import courses
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from enrollment.api import add_enrollment
|
||||
from microsite_configuration import microsite
|
||||
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
|
||||
from student.models import CourseEnrollment
|
||||
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
|
||||
from util.json_request import JsonResponse
|
||||
from verify_student.models import SoftwareSecurePhotoVerification
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -92,10 +97,11 @@ class BasketsView(APIView):
|
||||
self._enroll(course_key, user)
|
||||
return DetailResponse(msg)
|
||||
|
||||
# Setup the API and report any errors if settings are not valid.
|
||||
# Setup the API
|
||||
|
||||
try:
|
||||
api = EcommerceAPI()
|
||||
except InvalidConfigurationError:
|
||||
api = ecommerce_api_client(user)
|
||||
except ValueError:
|
||||
self._enroll(course_key, user)
|
||||
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key))
|
||||
log.debug(msg)
|
||||
@@ -103,34 +109,31 @@ class BasketsView(APIView):
|
||||
|
||||
# Make the API call
|
||||
try:
|
||||
response_data = api.create_basket(
|
||||
user,
|
||||
honor_mode.sku,
|
||||
payment_processor="cybersource",
|
||||
)
|
||||
response_data = api.baskets.post({
|
||||
'products': [{'sku': honor_mode.sku}],
|
||||
'checkout': True,
|
||||
'payment_processor_name': 'cybersource'
|
||||
})
|
||||
|
||||
payment_data = response_data["payment_data"]
|
||||
if payment_data is not None:
|
||||
# it is time to start the payment flow.
|
||||
# NOTE this branch does not appear to be used at the moment.
|
||||
if payment_data:
|
||||
# Pass data to the client to begin the payment flow.
|
||||
return JsonResponse(payment_data)
|
||||
elif response_data['order']:
|
||||
# the order was completed immediately because there was no charge.
|
||||
# The order was completed immediately because there isno charge.
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=response_data['order']['number'])
|
||||
log.debug(msg)
|
||||
return DetailResponse(msg)
|
||||
else:
|
||||
# Enroll in the honor mode directly as a failsafe.
|
||||
# This MUST be removed when this code handles paid modes.
|
||||
self._enroll(course_key, user)
|
||||
msg = u'Unexpected response from basket endpoint.'
|
||||
log.error(
|
||||
msg + u' Could not enroll user %(username)s in course %(course_id)s.',
|
||||
{'username': user.id, 'course_id': course_id},
|
||||
)
|
||||
raise InvalidResponseError(msg)
|
||||
except ApiError as err:
|
||||
# The API will handle logging of the error.
|
||||
return InternalRequestErrorResponse(err.message)
|
||||
except (exceptions.SlumberBaseException, exceptions.Timeout) as ex:
|
||||
log.exception(ex.message)
|
||||
return InternalRequestErrorResponse(ex.message)
|
||||
|
||||
|
||||
@cache_page(1800)
|
||||
@@ -138,3 +141,29 @@ def checkout_cancel(_request):
|
||||
""" Checkout/payment cancellation view. """
|
||||
context = {'payment_support_email': microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)}
|
||||
return render_to_response("commerce/checkout_cancel.html", context)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def checkout_receipt(request):
|
||||
""" Receipt view. """
|
||||
context = {
|
||||
'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
|
||||
'verified': SoftwareSecurePhotoVerification.verification_valid_or_pending(request.user).exists()
|
||||
}
|
||||
return render_to_response('commerce/checkout_receipt.html', context)
|
||||
|
||||
|
||||
class BasketOrderView(APIView):
|
||||
""" Retrieve the order associated with a basket. """
|
||||
|
||||
authentication_classes = (SessionAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, *_args, **kwargs):
|
||||
""" HTTP handler. """
|
||||
try:
|
||||
order = ecommerce_api_client(request.user).baskets(kwargs['basket_id']).order.get()
|
||||
return JsonResponse(order)
|
||||
except exceptions.HttpNotFoundError:
|
||||
return JsonResponse(status=404)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
Tests for Shopping Cart views
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import copy
|
||||
import mock
|
||||
import pytz
|
||||
from urlparse import urlparse
|
||||
from decimal import Decimal
|
||||
@@ -29,9 +27,6 @@ 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
|
||||
@@ -93,7 +88,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
min_price=self.cost)
|
||||
self.course_mode.save()
|
||||
|
||||
#Saving another testing course mode
|
||||
# Saving another testing course mode
|
||||
self.testing_cost = 20
|
||||
self.testing_course = CourseFactory.create(org='edX', number='888', display_name='Testing Super Course')
|
||||
self.testing_course_mode = CourseMode(course_id=self.testing_course.id,
|
||||
@@ -868,112 +863,10 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
'unit_cost': 40,
|
||||
'quantity': 1,
|
||||
'line_cost': 40,
|
||||
'line_desc': 'Honor Code Certificate for course Test Course'
|
||||
'line_desc': 'Honor Code Certificate for course Test Course',
|
||||
'course_key': unicode(self.verified_course_key)
|
||||
})
|
||||
|
||||
@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 = {
|
||||
'status': OrderStatus.COMPLETE,
|
||||
'number': EcommerceApiTestMixin.ORDER_NUMBER,
|
||||
'total_excl_tax': 40.0,
|
||||
'currency': 'USD',
|
||||
'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'},
|
||||
]}],
|
||||
'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)
|
||||
@@ -997,13 +890,15 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
'unit_cost': 40,
|
||||
'quantity': 1,
|
||||
'line_cost': 40,
|
||||
'line_desc': 'Registration for Course: Robot Super Course'
|
||||
'line_desc': 'Registration for Course: Robot Super Course',
|
||||
'course_key': unicode(self.course_key)
|
||||
})
|
||||
self.assertEqual(items[1], {
|
||||
'unit_cost': 40,
|
||||
'quantity': 1,
|
||||
'line_cost': 40,
|
||||
'line_desc': 'Honor Code Certificate for course Test Course'
|
||||
'line_desc': 'Honor Code Certificate for course Test Course',
|
||||
'course_key': unicode(self.verified_course_key)
|
||||
})
|
||||
|
||||
def test_receipt_json_refunded(self):
|
||||
|
||||
@@ -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>[-\w]+)/$', 'show_receipt'),
|
||||
url(r'^receipt/(?P<ordernum>[0-9]*)/$', '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,7 +1,6 @@
|
||||
import logging
|
||||
import datetime
|
||||
import decimal
|
||||
import dateutil
|
||||
import pytz
|
||||
from ipware.ip import get_ip
|
||||
from django.db.models import Q
|
||||
@@ -13,9 +12,6 @@ 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
|
||||
@@ -824,91 +820,20 @@ 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, ValueError):
|
||||
if is_json_request:
|
||||
return _get_external_order(request, ordernum)
|
||||
else:
|
||||
raise Http404('Order not found!')
|
||||
except Order.DoesNotExist:
|
||||
raise Http404('Order not found!')
|
||||
|
||||
if order.user != request.user or order.status not in ['purchased', 'refunded']:
|
||||
raise Http404('Order not found!')
|
||||
|
||||
if is_json_request:
|
||||
if 'application/json' in request.META.get('HTTP_ACCEPT', ""):
|
||||
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.
|
||||
|
||||
@@ -946,7 +871,8 @@ def _show_receipt_json(order):
|
||||
'quantity': item.qty,
|
||||
'unit_cost': item.unit_cost,
|
||||
'line_cost': item.line_cost,
|
||||
'line_desc': item.line_desc
|
||||
'line_desc': item.line_desc,
|
||||
'course_key': unicode(getattr(item, 'course_id'))
|
||||
}
|
||||
for item in OrderItem.objects.filter(order=order).select_subclasses()
|
||||
]
|
||||
|
||||
@@ -27,7 +27,6 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
|
||||
from commerce.exceptions import ApiError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
@@ -8,6 +8,7 @@ import decimal
|
||||
import datetime
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
from pytz import UTC
|
||||
from ipware.ip import get_ip
|
||||
from django.conf import settings
|
||||
@@ -24,6 +25,7 @@ from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.mail import send_mail
|
||||
from ecommerce_api_client.exceptions import SlumberBaseException
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -33,8 +35,7 @@ from edxmako.shortcuts import render_to_response, render_to_string
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings
|
||||
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
|
||||
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.exceptions import ApiError
|
||||
from commerce import ecommerce_api_client
|
||||
from course_modes.models import CourseMode
|
||||
from student.models import CourseEnrollment
|
||||
from student.views import reverification_info
|
||||
@@ -383,7 +384,7 @@ class PayAndVerifyView(View):
|
||||
# get available payment processors
|
||||
if unexpired_paid_course_mode.sku:
|
||||
# transaction will be conducted via ecommerce service
|
||||
processors = EcommerceAPI().get_processors(request.user)
|
||||
processors = ecommerce_api_client(request.user).get_processors()
|
||||
else:
|
||||
# transaction will be conducted using legacy shopping cart
|
||||
processors = [settings.CC_PROCESSOR_NAME]
|
||||
@@ -655,14 +656,14 @@ class PayAndVerifyView(View):
|
||||
def checkout_with_ecommerce_service(user, course_key, course_mode, processor): # pylint: disable=invalid-name
|
||||
""" Create a new basket and trigger immediate checkout, using the E-Commerce API. """
|
||||
try:
|
||||
api = EcommerceAPI()
|
||||
api = ecommerce_api_client(user)
|
||||
# Make an API call to create the order and retrieve the results
|
||||
response_data = api.create_basket(user, course_mode.sku, processor)
|
||||
response_data = api.create_basket(course_mode.sku, processor)
|
||||
# Pass the payment parameters directly from the API response.
|
||||
return response_data.get('payment_data')
|
||||
except ApiError:
|
||||
except SlumberBaseException:
|
||||
params = {'username': user.username, 'mode': course_mode.slug, 'course_id': unicode(course_key)}
|
||||
log.error('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
|
||||
log.exception('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
201
lms/static/js/commerce/views/receipt_view.js
Normal file
201
lms/static/js/commerce/views/receipt_view.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* View for the receipt page.
|
||||
*/
|
||||
var edx = edx || {};
|
||||
|
||||
(function ($, _, _s, Backbone) {
|
||||
'use strict';
|
||||
|
||||
edx.commerce = edx.commerce || {};
|
||||
|
||||
edx.commerce.ReceiptView = Backbone.View.extend({
|
||||
useEcommerceApi: true,
|
||||
|
||||
initialize: function () {
|
||||
this.useEcommerceApi = !!($.url('?basket_id'));
|
||||
_.bindAll(this, 'renderReceipt', 'renderError');
|
||||
|
||||
/* Mix non-conflicting functions from underscore.string (all but include, contains, and reverse) into
|
||||
* the Underscore namespace.
|
||||
*/
|
||||
_.mixin(_s.exports());
|
||||
|
||||
this.render();
|
||||
},
|
||||
|
||||
renderReceipt: function (data) {
|
||||
var templateHtml = $("#receipt-tpl").html(),
|
||||
context = {
|
||||
platformName: this.$el.data('platform-name'),
|
||||
verified: this.$el.data('verified').toLowerCase() === 'true'
|
||||
};
|
||||
|
||||
// Add the receipt info to the template context
|
||||
_.extend(context, {
|
||||
receipt: this.receiptContext(data),
|
||||
courseKey: this.getOrderCourseKey(data)
|
||||
});
|
||||
|
||||
this.$el.html(_.template(templateHtml, context));
|
||||
|
||||
this.trackLinks();
|
||||
},
|
||||
|
||||
renderError: function () {
|
||||
// Display an error
|
||||
$('#error-container').removeClass('hidden');
|
||||
},
|
||||
|
||||
render: function () {
|
||||
var self = this,
|
||||
orderId = $.url('?basket_id') || $.url('?payment-order-num');
|
||||
|
||||
if (orderId) {
|
||||
// Get the order details
|
||||
self.getReceiptData(orderId).then(self.renderReceipt, self.renderError);
|
||||
} else {
|
||||
self.renderError();
|
||||
}
|
||||
},
|
||||
|
||||
trackLinks: function () {
|
||||
var $verifyNowButton = $('#verify_now_button'),
|
||||
$verifyLaterButton = $('#verify_later_button');
|
||||
|
||||
// Track a virtual pageview, for easy funnel reconstruction.
|
||||
window.analytics.page('payment', 'receipt');
|
||||
|
||||
// Track the user's decision to verify immediately
|
||||
window.analytics.trackLink($verifyNowButton, 'edx.bi.user.verification.immediate', {
|
||||
category: 'verification'
|
||||
});
|
||||
|
||||
// Track the user's decision to defer their verification
|
||||
window.analytics.trackLink($verifyLaterButton, 'edx.bi.user.verification.deferred', {
|
||||
category: 'verification'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve receipt data from Oscar (via LMS).
|
||||
* @param {int} basketId The basket that was purchased.
|
||||
* @return {object} JQuery Promise.
|
||||
*/
|
||||
getReceiptData: function (basketId) {
|
||||
var urlFormat = this.useEcommerceApi ? '/commerce/baskets/%s/order/' : '/shoppingcart/receipt/%s/';
|
||||
|
||||
return $.ajax({
|
||||
url: _.sprintf(urlFormat, basketId),
|
||||
type: 'GET',
|
||||
dataType: 'json'
|
||||
}).retry({times: 5, timeout: 2000, statusCodes: [404]});
|
||||
},
|
||||
|
||||
/**
|
||||
* Construct the template context from data received
|
||||
* from the E-Commerce API.
|
||||
*
|
||||
* @param {object} order Receipt data received from the server
|
||||
* @return {object} Receipt template context.
|
||||
*/
|
||||
receiptContext: function (order) {
|
||||
var self = this,
|
||||
receiptContext;
|
||||
|
||||
if (this.useEcommerceApi) {
|
||||
receiptContext = {
|
||||
orderNum: order.number,
|
||||
currency: order.currency,
|
||||
purchasedDatetime: order.date_placed,
|
||||
totalCost: self.formatMoney(order.total_excl_tax),
|
||||
isRefunded: false,
|
||||
billedTo: {
|
||||
firstName: order.billing_address.first_name,
|
||||
lastName: order.billing_address.last_name,
|
||||
city: order.billing_address.city,
|
||||
state: order.billing_address.state,
|
||||
postalCode: order.billing_address.postcode,
|
||||
country: order.billing_address.country
|
||||
},
|
||||
items: []
|
||||
};
|
||||
|
||||
receiptContext.items = _.map(
|
||||
order.lines,
|
||||
function (line) {
|
||||
return {
|
||||
lineDescription: line.description,
|
||||
cost: self.formatMoney(line.line_price_excl_tax)
|
||||
};
|
||||
}
|
||||
);
|
||||
} else {
|
||||
receiptContext = {
|
||||
orderNum: order.orderNum,
|
||||
currency: order.currency,
|
||||
purchasedDatetime: order.purchase_datetime,
|
||||
totalCost: self.formatMoney(order.total_cost),
|
||||
isRefunded: order.status === "refunded",
|
||||
billedTo: {
|
||||
firstName: order.billed_to.first_name,
|
||||
lastName: order.billed_to.last_name,
|
||||
city: order.billed_to.city,
|
||||
state: order.billed_to.state,
|
||||
postalCode: order.billed_to.postal_code,
|
||||
country: order.billed_to.country
|
||||
},
|
||||
items: []
|
||||
};
|
||||
|
||||
receiptContext.items = _.map(
|
||||
order.items,
|
||||
function (item) {
|
||||
return {
|
||||
lineDescription: item.line_desc,
|
||||
cost: self.formatMoney(item.line_cost)
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return receiptContext;
|
||||
},
|
||||
|
||||
getOrderCourseKey: function (order) {
|
||||
var length, items;
|
||||
if (this.useEcommerceApi) {
|
||||
length = order.lines.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
var line = order.lines[i],
|
||||
attribute_values = _.filter(line.product.attribute_values, function (attribute) {
|
||||
return attribute.name === 'course_key'
|
||||
});
|
||||
|
||||
// This method assumes that all items in the order are related to a single course.
|
||||
if (attribute_values.length > 0) {
|
||||
return attribute_values[0]['value'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items = _.filter(order.items, function (item) {
|
||||
return item.course_key;
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
return items[0].course_key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
formatMoney: function (moneyStr) {
|
||||
return Number(moneyStr).toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
new edx.commerce.ReceiptView({
|
||||
el: $('#receipt-container')
|
||||
});
|
||||
|
||||
})(jQuery, _, _.str, Backbone);
|
||||
83
lms/static/js/vendor/jquery.ajax-retry.js
vendored
Normal file
83
lms/static/js/vendor/jquery.ajax-retry.js
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* jquery.ajax-retry
|
||||
* https://github.com/johnkpaul/jquery-ajax-retry
|
||||
*
|
||||
* Copyright (c) 2012 John Paul
|
||||
* Licensed under the MIT license.
|
||||
*/
|
||||
(function(factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['jquery'], factory);
|
||||
} else if (typeof exports === 'object') {
|
||||
// Node/CommonJS
|
||||
factory(require('jquery'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory(jQuery);
|
||||
}
|
||||
})(function($) {
|
||||
|
||||
// enhance all ajax requests with our retry API
|
||||
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
|
||||
jqXHR.retry = function(opts) {
|
||||
if(opts.timeout) {
|
||||
this.timeout = opts.timeout;
|
||||
}
|
||||
if (opts.statusCodes) {
|
||||
this.statusCodes = opts.statusCodes;
|
||||
}
|
||||
return this.pipe(null, pipeFailRetry(this, opts));
|
||||
};
|
||||
});
|
||||
|
||||
// generates a fail pipe function that will retry `jqXHR` `times` more times
|
||||
function pipeFailRetry(jqXHR, opts) {
|
||||
var times = opts.times;
|
||||
var timeout = jqXHR.timeout;
|
||||
|
||||
// takes failure data as input, returns a new deferred
|
||||
return function(input, status, msg) {
|
||||
var ajaxOptions = this;
|
||||
var output = new $.Deferred();
|
||||
var retryAfter = jqXHR.getResponseHeader('Retry-After');
|
||||
|
||||
// whenever we do make this request, pipe its output to our deferred
|
||||
function nextRequest() {
|
||||
$.ajax(ajaxOptions)
|
||||
.retry({times: times - 1, timeout: opts.timeout})
|
||||
.pipe(output.resolve, output.reject);
|
||||
}
|
||||
|
||||
if (times > 1 && (!jqXHR.statusCodes || $.inArray(input.status, jqXHR.statusCodes) > -1)) {
|
||||
// implement Retry-After rfc
|
||||
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.37
|
||||
if (retryAfter) {
|
||||
// it must be a date
|
||||
if (isNaN(retryAfter)) {
|
||||
timeout = new Date(retryAfter).getTime() - $.now();
|
||||
// its a number in seconds
|
||||
} else {
|
||||
timeout = parseInt(retryAfter, 10) * 1000;
|
||||
}
|
||||
// ensure timeout is a positive number
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
timeout = jqXHR.timeout;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout !== undefined){
|
||||
setTimeout(nextRequest, timeout);
|
||||
} else {
|
||||
nextRequest();
|
||||
}
|
||||
} else {
|
||||
// no times left, reject our deferred with the current arguments
|
||||
output.rejectWith(this, arguments);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
}
|
||||
|
||||
});
|
||||
55
lms/templates/commerce/checkout_receipt.html
Normal file
55
lms/templates/commerce/checkout_receipt.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
<%block name="bodyclass">register verification-process step-requirements</%block>
|
||||
|
||||
<%block name="pagetitle">
|
||||
${_("Receipt")}
|
||||
</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<script type="text/template" id="receipt-tpl">
|
||||
<%static:include path="commerce/receipt.underscore" />
|
||||
</script>
|
||||
|
||||
</%block>
|
||||
<%block name="js_extra">
|
||||
<%static:js group='rwd_header_footer'/>
|
||||
<script src="${static.url('js/vendor/jquery.ajax-retry.js')}"></script>
|
||||
<script src="${static.url('js/vendor/underscore-min.js')}"></script>
|
||||
<script src="${static.url('js/vendor/underscore.string.min.js')}"></script>
|
||||
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
|
||||
<script src="${static.url('js/src/tooltip_manager.js')}"></script>
|
||||
<script src="${static.url('js/commerce/views/receipt_view.js')}"></script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div id="error-container" class="hidden">
|
||||
<div id="error" class="wrapper-msg wrapper-msg-activate">
|
||||
<div class=" msg msg-activate">
|
||||
<i class="msg-icon icon fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
<div class="msg-content">
|
||||
<h3 class="title">
|
||||
<span class="sr">${ _("Error:") }</span>
|
||||
${ _("Error") }
|
||||
</h3>
|
||||
<div class="copy">
|
||||
<p>${ _("Could not retrieve payment information") }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<section class="wrapper carousel">
|
||||
<div id="receipt-container" class="pay-and-verify" data-platform-name='${platform_name}' data-verified='${verified}'>
|
||||
<h1>${_("Loading Order Data...")}</h1>
|
||||
<span>${ _("Please wait while we retrieve your order details.") }</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
97
lms/templates/commerce/receipt.underscore
Normal file
97
lms/templates/commerce/receipt.underscore
Normal file
@@ -0,0 +1,97 @@
|
||||
<div class="wrapper-content-main payment-confirmation-step">
|
||||
<article class="content-main">
|
||||
<h3 class="title">
|
||||
<%= gettext( "Thank you! We have received your payment." ) %>
|
||||
</h3>
|
||||
|
||||
<% if ( receipt ) { %>
|
||||
<div class="list-info">
|
||||
<div class="info-item payment-info">
|
||||
<div class="copy">
|
||||
<p><%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %></p>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-report">
|
||||
<table class="report report-receipt">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" ><%- gettext( "Order No." ) %></th>
|
||||
<th scope="col" ><%- gettext( "Description" ) %></th>
|
||||
<th scope="col" ><%- gettext( "Date" ) %></th>
|
||||
<th scope="col" ><%- gettext( "Amount" ) %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% for ( var i = 0; i < receipt.items.length; i++ ) { %>
|
||||
<% if ( receipt.isRefunded ) { %>
|
||||
<td><del><%- receipt.orderNum %></del></td>
|
||||
<td><del><%- receipt.items[i].lineDescription %></del></td>
|
||||
<td><del><%- receipt.purchasedDatetime %></del></td>
|
||||
<td><del><%- receipt.items[i].cost %> (<%- receipt.currency.toUpperCase() %>)</del></td>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td><%- receipt.orderNum %></td>
|
||||
<td><%- receipt.items[i].lineDescription %></td>
|
||||
<td><%- receipt.purchasedDatetime %></td>
|
||||
<td><%- receipt.items[i].cost %> (<%- receipt.currency.toUpperCase() %>)</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row" class="total-label" colspan="1"><%- gettext( "Total" ) %></th>
|
||||
<td class="total-value" colspan="3">
|
||||
<span class="value-amount"><%- receipt.totalCost %></span>
|
||||
<span class="value-currency">(<%- receipt.currency.toUpperCase() %>)</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<% if ( receipt.isRefunded ) { %>
|
||||
<div class="msg msg-refunds">
|
||||
<h4 class="title sr"><%- gettext( "Please Note" ) %>: </h4>
|
||||
<div class="copy">
|
||||
<p><%- gettext( "Crossed out items have been refunded." ) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="copy">
|
||||
<p><%- gettext( "Billed to" ) %>:
|
||||
<span class="name-first"><%- receipt.billedTo.firstName %></span>
|
||||
<span class="name-last"><%- receipt.billedTo.lastName %></span>
|
||||
(<span class="address-city"><%- receipt.billedTo.city %></span>,
|
||||
<span class="address-state"><%- receipt.billedTo.state %></span>
|
||||
<span class="address-postalcode"><%- receipt.billedTo.postalCode %></span>
|
||||
<span class="address-country"><%- receipt.billedTo.country.toUpperCase() %></span>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p class="no-content"><%- gettext( "No receipt available" ) %></p>
|
||||
<% } %>
|
||||
|
||||
<nav class="nav-wizard is-ready">
|
||||
<% if ( verified ) { %>
|
||||
<a class="next action-primary right" href="/dashboard"><%- gettext( "Go to Dashboard" ) %></a>
|
||||
<% } else { %>
|
||||
<a id="verify_later_button" class="next action-secondary verify-later nav-link" href="/dashboard" data-tooltip="<%- _.sprintf( gettext( "If you don't verify your identity now, you can still explore your course from your dashboard. You will receive periodic reminders from %(platformName)s to verify your identity." ), { platformName: platformName } ) %>">
|
||||
<%- gettext( "Want to confirm your identity later?" ) %>
|
||||
</a>
|
||||
|
||||
<a id="verify_now_button"
|
||||
class="next action-primary right"
|
||||
href="<%- _.sprintf( '/verify_student/verify-now/%(courseKey)s/', { courseKey: courseKey } ) %>"
|
||||
>
|
||||
<%- gettext( "Verify Now" ) %>
|
||||
</a>
|
||||
<% } %>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
@@ -5,8 +5,8 @@
|
||||
</h3>
|
||||
|
||||
<% if ( receipt ) { %>
|
||||
<ul class="list-info">
|
||||
<li class="info-item payment-info">
|
||||
<div class="list-info">
|
||||
<div class="info-item payment-info">
|
||||
<div class="copy">
|
||||
<p><%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %></p>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row" class="total-label" colspan="1"><%- gettext( "Total" ) %></th>
|
||||
<td claass="total-value" colspan="3">
|
||||
<td class="total-value" colspan="3">
|
||||
<span class="value-amount"><%- receipt.totalCost %></span>
|
||||
<span class="value-currency">(<%- receipt.currency.toUpperCase() %>)</span>
|
||||
</td>
|
||||
@@ -71,8 +71,8 @@
|
||||
<span class="address-country"><%- receipt.billedTo.country.toUpperCase() %></span>)
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p class="no-content"><%- gettext( "No receipt available" ) %></p>
|
||||
<% } %>
|
||||
|
||||
@@ -48,6 +48,7 @@ git+https://github.com/edx/edx-lint.git@8bf82a32ecb8598c415413df66f5232ab8d974e9
|
||||
-e git+https://github.com/edx/xblock-utils.git@581ed636c862b286002bb9a3724cc883570eb54c#egg=xblock-utils
|
||||
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
|
||||
-e git+https://github.com/edx/edx-reverification-block.git@5da515ef229e73a137d366beb05ea4aea5881960#egg=edx-reverification-block
|
||||
git+https://github.com/edx/ecommerce-api-client.git@0.2.0#egg=ecommerce-api-client
|
||||
|
||||
# Third Party XBlocks
|
||||
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
|
||||
|
||||
Reference in New Issue
Block a user