diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index f54d6c45e0..d3fff745e9 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -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( diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 4d19144dc1..656fcdd770 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -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) diff --git a/lms/djangoapps/commerce/__init__.py b/lms/djangoapps/commerce/__init__.py index 9d684dfb90..dac799ea63 100644 --- a/lms/djangoapps/commerce/__init__.py +++ b/lms/djangoapps/commerce/__init__.py @@ -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) diff --git a/lms/djangoapps/commerce/api.py b/lms/djangoapps/commerce/api.py deleted file mode 100644 index acb19ffaf6..0000000000 --- a/lms/djangoapps/commerce/api.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/commerce/exceptions.py b/lms/djangoapps/commerce/exceptions.py index 1ce359a130..25b1535dd5 100644 --- a/lms/djangoapps/commerce/exceptions.py +++ b/lms/djangoapps/commerce/exceptions.py @@ -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 diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py index 580b0f5170..0024e50cf1 100644 --- a/lms/djangoapps/commerce/tests/__init__.py +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -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 diff --git a/lms/djangoapps/commerce/tests/test_api.py b/lms/djangoapps/commerce/tests/test_api.py deleted file mode 100644 index 5caf643cf4..0000000000 --- a/lms/djangoapps/commerce/tests/test_api.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 3e637fe30e..b9a43ebae8 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/commerce/urls.py b/lms/djangoapps/commerce/urls.py index 390b3dc67d..4046bf7530 100644 --- a/lms/djangoapps/commerce/urls.py +++ b/lms/djangoapps/commerce/urls.py @@ -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[\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"), ) diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index 8de4fbff8d..da6042b00e 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index dc254cd620..d79a18f69c 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -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): diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 7ad0d2f149..9363521ddc 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[-\w]+)/$', 'show_receipt'), + url(r'^receipt/(?P[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 diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index b2f3a56b1d..387ae6e1fb 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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() ] diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index ea1e3ce4b5..887a7a37ad 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -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 diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 5d52b0c45c..d8f7ab5ed9 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -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 diff --git a/lms/static/js/commerce/views/receipt_view.js b/lms/static/js/commerce/views/receipt_view.js new file mode 100644 index 0000000000..07466d19c5 --- /dev/null +++ b/lms/static/js/commerce/views/receipt_view.js @@ -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); diff --git a/lms/static/js/vendor/jquery.ajax-retry.js b/lms/static/js/vendor/jquery.ajax-retry.js new file mode 100644 index 0000000000..9c3b173a52 --- /dev/null +++ b/lms/static/js/vendor/jquery.ajax-retry.js @@ -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; + }; + } + +}); diff --git a/lms/templates/commerce/checkout_receipt.html b/lms/templates/commerce/checkout_receipt.html new file mode 100644 index 0000000000..2989e84597 --- /dev/null +++ b/lms/templates/commerce/checkout_receipt.html @@ -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 name="pagetitle"> +${_("Receipt")} + + +<%block name="header_extras"> + + + +<%block name="js_extra"> +<%static:js group='rwd_header_footer'/> + + + + + + + + +<%block name="content"> + + +
+ +
+ diff --git a/lms/templates/commerce/receipt.underscore b/lms/templates/commerce/receipt.underscore new file mode 100644 index 0000000000..08484d4be1 --- /dev/null +++ b/lms/templates/commerce/receipt.underscore @@ -0,0 +1,97 @@ +
+
+

+ <%= gettext( "Thank you! We have received your payment." ) %> +

+ + <% if ( receipt ) { %> +
+
+
+

<%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %>

+
+ +
+ + + + + + + + + + + + <% for ( var i = 0; i < receipt.items.length; i++ ) { %> + <% if ( receipt.isRefunded ) { %> + + + + + <% } else { %> + + + + + + + <% } %> + <% } %> + + + + + + + + +
<%- gettext( "Order No." ) %><%- gettext( "Description" ) %><%- gettext( "Date" ) %><%- gettext( "Amount" ) %>
<%- receipt.orderNum %><%- receipt.items[i].lineDescription %><%- receipt.purchasedDatetime %><%- receipt.items[i].cost %> (<%- receipt.currency.toUpperCase() %>)
<%- receipt.orderNum %><%- receipt.items[i].lineDescription %><%- receipt.purchasedDatetime %><%- receipt.items[i].cost %> (<%- receipt.currency.toUpperCase() %>)
<%- gettext( "Total" ) %> + <%- receipt.totalCost %> + (<%- receipt.currency.toUpperCase() %>) +
+ + <% if ( receipt.isRefunded ) { %> +
+

<%- gettext( "Please Note" ) %>:

+
+

<%- gettext( "Crossed out items have been refunded." ) %>

+
+
+ <% } %> +
+ +
+

<%- gettext( "Billed to" ) %>: + <%- receipt.billedTo.firstName %> + <%- receipt.billedTo.lastName %> + (<%- receipt.billedTo.city %>, + <%- receipt.billedTo.state %> + <%- receipt.billedTo.postalCode %> + <%- receipt.billedTo.country.toUpperCase() %>) +

+
+
+
+ <% } else { %> +

<%- gettext( "No receipt available" ) %>

+ <% } %> + + +
+
diff --git a/lms/templates/verify_student/payment_confirmation_step.underscore b/lms/templates/verify_student/payment_confirmation_step.underscore index e91761c4ed..a69ad394cc 100644 --- a/lms/templates/verify_student/payment_confirmation_step.underscore +++ b/lms/templates/verify_student/payment_confirmation_step.underscore @@ -5,8 +5,8 @@ <% if ( receipt ) { %> -
    -
  • +
    +

    <%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %>

    @@ -43,7 +43,7 @@ <%- gettext( "Total" ) %> - + <%- receipt.totalCost %> (<%- receipt.currency.toUpperCase() %>) @@ -71,8 +71,8 @@ <%- receipt.billedTo.country.toUpperCase() %>)

    -
  • -
+ + <% } else { %>

<%- gettext( "No receipt available" ) %>

<% } %> diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 3368a251b7..5c18392a2f 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -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