Use ecommerce api v2.
XCOM-213 XCOM-214
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
define(['sinon', 'underscore'], function(sinon, _) {
|
||||
var fakeServer, fakeRequests, expectRequest, expectJsonRequest,
|
||||
respondWithJson, respondWithError, respondWithTextError, responseWithNoContent;
|
||||
var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest,
|
||||
respondWithJson, respondWithError, respondWithTextError, respondWithNoContent;
|
||||
|
||||
/* These utility methods are used by Jasmine tests to create a mock server or
|
||||
* get reference to mock requests. In either case, the cleanup (restore) is done with
|
||||
@@ -68,6 +68,20 @@ define(['sinon', 'underscore'], function(sinon, _) {
|
||||
expect(JSON.parse(request.requestBody)).toEqual(jsonRequest);
|
||||
};
|
||||
|
||||
/**
|
||||
* Intended for use with POST requests using application/x-www-form-urlencoded.
|
||||
*/
|
||||
expectPostRequest = function(requests, url, body, requestIndex) {
|
||||
var request;
|
||||
if (_.isUndefined(requestIndex)) {
|
||||
requestIndex = requests.length - 1;
|
||||
}
|
||||
request = requests[requestIndex];
|
||||
expect(request.url).toEqual(url);
|
||||
expect(request.method).toEqual("POST");
|
||||
expect(_.difference(request.requestBody.split('&'), body.split('&'))).toEqual([]);
|
||||
};
|
||||
|
||||
respondWithJson = function(requests, jsonResponse, requestIndex) {
|
||||
if (_.isUndefined(requestIndex)) {
|
||||
requestIndex = requests.length - 1;
|
||||
@@ -122,6 +136,7 @@ define(['sinon', 'underscore'], function(sinon, _) {
|
||||
'requests': fakeRequests,
|
||||
'expectRequest': expectRequest,
|
||||
'expectJsonRequest': expectJsonRequest,
|
||||
'expectPostRequest': expectPostRequest,
|
||||
'respondWithJson': respondWithJson,
|
||||
'respondWithError': respondWithError,
|
||||
'respondWithTextError': respondWithTextError,
|
||||
|
||||
@@ -76,7 +76,7 @@ class PaymentAndVerificationFlow(PageObject):
|
||||
|
||||
def proceed_to_payment(self):
|
||||
"""Interact with the payment button."""
|
||||
self.q(css="#pay_button").click()
|
||||
self.q(css=".payment-button").click()
|
||||
|
||||
FakePaymentPage(self.browser, self._course_id).wait_for_page()
|
||||
|
||||
|
||||
@@ -60,26 +60,57 @@ class EcommerceAPI(object):
|
||||
}
|
||||
url = '{base_url}/orders/{order_number}/'.format(base_url=self.url, order_number=order_number)
|
||||
return requests.get(url, headers=headers, timeout=self.timeout)
|
||||
return self._call_ecommerce_service(get)
|
||||
|
||||
def create_order(self, user, sku):
|
||||
data = self._call_ecommerce_service(get)
|
||||
return data['number'], data['status'], data
|
||||
|
||||
def get_processors(self, user):
|
||||
"""
|
||||
Create a new order.
|
||||
Retrieve the list of available payment processors.
|
||||
|
||||
Arguments
|
||||
user -- User for which the order should be created.
|
||||
sku -- SKU of the course seat being ordered.
|
||||
|
||||
Returns a tuple with the order number, order status, API response data.
|
||||
Returns a list of strings.
|
||||
"""
|
||||
def create():
|
||||
"""Internal service call to create an order. """
|
||||
def get():
|
||||
"""Internal service call to retrieve the processor list. """
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
url = '{}/orders/'.format(self.url)
|
||||
return requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout)
|
||||
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
|
||||
@@ -92,7 +123,7 @@ class EcommerceAPI(object):
|
||||
Arguments
|
||||
call -- A callable function that makes a request to the E-Commerce Service.
|
||||
|
||||
Returns a tuple with the order number, order status, API response data.
|
||||
Returns a dict of JSON-decoded API response data.
|
||||
"""
|
||||
try:
|
||||
response = call()
|
||||
@@ -109,7 +140,7 @@ class EcommerceAPI(object):
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == HTTP_200_OK:
|
||||
return data['number'], data['status'], data
|
||||
return data
|
||||
else:
|
||||
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
|
||||
msg_kwargs = {
|
||||
|
||||
@@ -4,13 +4,8 @@
|
||||
class OrderStatus(object):
|
||||
"""Constants representing all known order statuses. """
|
||||
OPEN = 'Open'
|
||||
ORDER_CANCELLED = 'Order Cancelled'
|
||||
BEING_PROCESSED = 'Being Processed'
|
||||
PAYMENT_CANCELLED = 'Payment Cancelled'
|
||||
PAID = 'Paid'
|
||||
FULFILLMENT_ERROR = 'Fulfillment Error'
|
||||
COMPLETE = 'Complete'
|
||||
REFUNDED = 'Refunded'
|
||||
|
||||
|
||||
class Messages(object):
|
||||
|
||||
@@ -6,7 +6,6 @@ import jwt
|
||||
import mock
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.constants import OrderStatus
|
||||
|
||||
|
||||
class EcommerceApiTestMixin(object):
|
||||
@@ -14,12 +13,19 @@ class EcommerceApiTestMixin(object):
|
||||
|
||||
ECOMMERCE_API_URL = 'http://example.com/api'
|
||||
ECOMMERCE_API_SIGNING_KEY = 'edx'
|
||||
BASKET_ID = 7
|
||||
ORDER_NUMBER = '100004'
|
||||
PROCESSOR = 'test-processor'
|
||||
PAYMENT_DATA = {
|
||||
'payment_processor_name': PROCESSOR,
|
||||
'payment_form_data': {},
|
||||
'payment_page_url': 'http://example.com/pay',
|
||||
}
|
||||
ORDER_DATA = {'number': ORDER_NUMBER}
|
||||
ECOMMERCE_API_SUCCESSFUL_BODY = {
|
||||
'status': OrderStatus.COMPLETE,
|
||||
'number': ORDER_NUMBER,
|
||||
'payment_processor': 'cybersource',
|
||||
'payment_parameters': {'orderNumber': ORDER_NUMBER}
|
||||
'id': BASKET_ID,
|
||||
'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
|
||||
|
||||
@@ -28,14 +34,18 @@ class EcommerceApiTestMixin(object):
|
||||
expected_jwt = jwt.encode({'username': user.username, 'email': user.email}, key)
|
||||
self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt))
|
||||
|
||||
def assertValidOrderRequest(self, request, user, jwt_signing_key, sku):
|
||||
def assertValidBasketRequest(self, request, user, jwt_signing_key, sku, processor):
|
||||
""" Verifies that an order request to the E-Commerce Service is valid. """
|
||||
self.assertValidJWTAuthHeader(request, user, jwt_signing_key)
|
||||
|
||||
self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku))
|
||||
expected_body_data = {
|
||||
'products': [{'sku': sku}],
|
||||
'checkout': True,
|
||||
'payment_processor_name': processor
|
||||
}
|
||||
self.assertEqual(json.loads(request.body), expected_body_data)
|
||||
self.assertEqual(request.headers['Content-Type'], 'application/json')
|
||||
|
||||
def _mock_ecommerce_api(self, status=200, body=None):
|
||||
def _mock_ecommerce_api(self, status=200, body=None, is_payment_required=False):
|
||||
"""
|
||||
Mock calls to the E-Commerce API.
|
||||
|
||||
@@ -43,27 +53,25 @@ class EcommerceApiTestMixin(object):
|
||||
"""
|
||||
self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.')
|
||||
|
||||
url = self.ECOMMERCE_API_URL + '/orders/'
|
||||
body = body or self.ECOMMERCE_API_SUCCESSFUL_BODY_JSON
|
||||
url = self.ECOMMERCE_API_URL + '/baskets/'
|
||||
if body is None:
|
||||
response_data = {'id': self.BASKET_ID, 'payment_data': None, 'order': None}
|
||||
if is_payment_required:
|
||||
response_data['payment_data'] = self.PAYMENT_DATA
|
||||
else:
|
||||
response_data['order'] = {'number': self.ORDER_NUMBER}
|
||||
body = json.dumps(response_data)
|
||||
httpretty.register_uri(httpretty.POST, url, status=status, body=body)
|
||||
|
||||
class mock_create_order(object): # pylint: disable=invalid-name
|
||||
""" Mocks calls to EcommerceAPI.create_order. """
|
||||
class mock_create_basket(object): # pylint: disable=invalid-name
|
||||
""" Mocks calls to EcommerceAPI.create_basket. """
|
||||
|
||||
patch = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
default_kwargs = {
|
||||
'return_value': (
|
||||
EcommerceApiTestMixin.ORDER_NUMBER,
|
||||
OrderStatus.COMPLETE,
|
||||
EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY
|
||||
)
|
||||
}
|
||||
|
||||
default_kwargs = {'return_value': EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY}
|
||||
default_kwargs.update(kwargs)
|
||||
|
||||
self.patch = mock.patch.object(EcommerceAPI, 'create_order', mock.Mock(**default_kwargs))
|
||||
self.patch = mock.patch.object(EcommerceAPI, 'create_basket', mock.Mock(**default_kwargs))
|
||||
|
||||
def __enter__(self):
|
||||
self.patch.start()
|
||||
|
||||
@@ -10,7 +10,6 @@ import httpretty
|
||||
from requests import Timeout
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.constants import OrderStatus
|
||||
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from student.tests.factories import UserFactory
|
||||
@@ -26,7 +25,7 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(EcommerceAPITests, self).setUp()
|
||||
self.url = reverse('commerce:orders')
|
||||
self.url = reverse('commerce:baskets')
|
||||
self.user = UserFactory()
|
||||
self.api = EcommerceAPI()
|
||||
|
||||
@@ -48,35 +47,40 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
|
||||
self.assertRaises(InvalidConfigurationError, EcommerceAPI)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_order(self):
|
||||
@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()
|
||||
number, status, body = self.api.create_order(self.user, self.SKU)
|
||||
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.assertValidOrderRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU)
|
||||
self.assertValidBasketRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU, self.PROCESSOR)
|
||||
|
||||
# Validate the data returned by the method
|
||||
self.assertEqual(number, self.ORDER_NUMBER)
|
||||
self.assertEqual(status, OrderStatus.COMPLETE)
|
||||
self.assertEqual(body, self.ECOMMERCE_API_SUCCESSFUL_BODY)
|
||||
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_order_with_invalid_http_status(self, status):
|
||||
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_order, self.user, self.SKU)
|
||||
self.assertRaises(InvalidResponseError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_order_with_invalid_json(self):
|
||||
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_order, self.user, self.SKU)
|
||||
self.assertRaises(InvalidResponseError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_order_with_timeout(self):
|
||||
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):
|
||||
@@ -85,4 +89,4 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
|
||||
|
||||
self._mock_ecommerce_api(body=request_callback)
|
||||
|
||||
self.assertRaises(TimeoutError, self.api.create_order, self.user, self.SKU)
|
||||
self.assertRaises(TimeoutError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.test.utils import override_settings
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from commerce.constants import OrderStatus, Messages
|
||||
from commerce.constants import Messages
|
||||
from commerce.exceptions import TimeoutError, ApiError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from course_modes.models import CourseMode
|
||||
@@ -22,7 +22,7 @@ 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 OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the commerce orders view.
|
||||
"""
|
||||
@@ -48,6 +48,11 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
actual = json.loads(response.content)['detail']
|
||||
self.assertEqual(actual, expected_msg)
|
||||
|
||||
def assertResponsePaymentData(self, response):
|
||||
""" Asserts correctness of a JSON body containing payment information. """
|
||||
actual_response = json.loads(response.content)
|
||||
self.assertEqual(actual_response, self.PAYMENT_DATA)
|
||||
|
||||
def assertValidEcommerceInternalRequestErrorResponse(self, response):
|
||||
""" Asserts the response is a valid response sent when the E-Commerce API is unavailable. """
|
||||
self.assertEqual(response.status_code, 500)
|
||||
@@ -60,8 +65,8 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
self.assert_no_events_were_emitted()
|
||||
|
||||
def setUp(self):
|
||||
super(OrdersViewTests, self).setUp()
|
||||
self.url = reverse('commerce:orders')
|
||||
super(BasketsViewTests, self).setUp()
|
||||
self.url = reverse('commerce:baskets')
|
||||
self.user = UserFactory()
|
||||
self._login()
|
||||
|
||||
@@ -113,7 +118,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
"""
|
||||
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_order(side_effect=TimeoutError):
|
||||
with self.mock_create_basket(side_effect=TimeoutError):
|
||||
response = self._post_to_view()
|
||||
|
||||
self.assertValidEcommerceInternalRequestErrorResponse(response)
|
||||
@@ -123,22 +128,24 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
"""
|
||||
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
|
||||
"""
|
||||
with self.mock_create_order(side_effect=ApiError):
|
||||
with self.mock_create_basket(side_effect=ApiError):
|
||||
response = self._post_to_view()
|
||||
|
||||
self.assertValidEcommerceInternalRequestErrorResponse(response)
|
||||
self.assertUserNotEnrolled()
|
||||
|
||||
def _test_successful_ecommerce_api_call(self):
|
||||
def _test_successful_ecommerce_api_call(self, is_completed=True):
|
||||
"""
|
||||
Verifies that the view contacts the E-Commerce API with the correct data and headers.
|
||||
"""
|
||||
with self.mock_create_order():
|
||||
response = self._post_to_view()
|
||||
response = self._post_to_view()
|
||||
|
||||
# Validate the response content
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER)
|
||||
self.assertResponseMessage(response, msg)
|
||||
if is_completed:
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER)
|
||||
self.assertResponseMessage(response, msg)
|
||||
else:
|
||||
self.assertResponsePaymentData(response)
|
||||
|
||||
@data(True, False)
|
||||
def test_course_with_honor_seat_sku(self, user_is_active):
|
||||
@@ -151,26 +158,30 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
self.user.is_active = user_is_active
|
||||
self.user.save() # pylint: disable=no-member
|
||||
|
||||
self._test_successful_ecommerce_api_call()
|
||||
return_value = {'id': self.BASKET_ID, 'payment_data': None, 'order': {'number': self.ORDER_NUMBER}}
|
||||
with self.mock_create_basket(return_value=return_value):
|
||||
self._test_successful_ecommerce_api_call()
|
||||
|
||||
def test_order_not_complete(self):
|
||||
with self.mock_create_order(return_value=(self.ORDER_NUMBER,
|
||||
OrderStatus.OPEN,
|
||||
self.ECOMMERCE_API_SUCCESSFUL_BODY)):
|
||||
response = self._post_to_view()
|
||||
self.assertEqual(response.status_code, 202)
|
||||
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=self.ORDER_NUMBER)
|
||||
self.assertResponseMessage(response, msg)
|
||||
@data(True, False)
|
||||
def test_course_with_paid_seat_sku(self, user_is_active):
|
||||
"""
|
||||
If the course has a SKU, the view should return data that the client
|
||||
will use to redirect the user to an external payment processor.
|
||||
"""
|
||||
# Set user's active flag
|
||||
self.user.is_active = user_is_active
|
||||
self.user.save() # pylint: disable=no-member
|
||||
|
||||
# TODO Eventually we should NOT be enrolling users directly from this view.
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
return_value = {'id': self.BASKET_ID, 'payment_data': self.PAYMENT_DATA, 'order': None}
|
||||
with self.mock_create_basket(return_value=return_value):
|
||||
self._test_successful_ecommerce_api_call(False)
|
||||
|
||||
def _test_course_without_sku(self):
|
||||
"""
|
||||
Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs.
|
||||
"""
|
||||
# Place an order
|
||||
with self.mock_create_order() as api_mock:
|
||||
with self.mock_create_basket() as api_mock:
|
||||
response = self._post_to_view()
|
||||
|
||||
# Validate the response content
|
||||
@@ -199,7 +210,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
"""
|
||||
If the E-Commerce Service is not configured, the view should enroll the user.
|
||||
"""
|
||||
with self.mock_create_order() as api_mock:
|
||||
with self.mock_create_basket() as api_mock:
|
||||
response = self._post_to_view()
|
||||
|
||||
# Validate the response
|
||||
@@ -219,7 +230,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
|
||||
sku=uuid4().hex.decode('ascii'))
|
||||
|
||||
with self.mock_create_order() as api_mock:
|
||||
with self.mock_create_basket() as api_mock:
|
||||
response = self._post_to_view()
|
||||
|
||||
# The view should return an error status code
|
||||
@@ -274,4 +285,17 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
self.assertIsNotNone(get_enrollment(self.user.username, unicode(self.course.id)))
|
||||
|
||||
self._test_successful_ecommerce_api_call()
|
||||
with self.mock_create_basket():
|
||||
self._test_successful_ecommerce_api_call(False)
|
||||
|
||||
|
||||
class OrdersViewTests(BasketsViewTests):
|
||||
"""
|
||||
Ensures that /orders/ points to and behaves like /baskets/, for backward
|
||||
compatibility with stale js clients during updates.
|
||||
|
||||
(XCOM-214) remove after release.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(OrdersViewTests, self).setUp()
|
||||
self.url = reverse('commerce:orders')
|
||||
|
||||
@@ -4,10 +4,12 @@ Defines the URL routes for this app.
|
||||
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
from .views import OrdersView, checkout_cancel
|
||||
from .views import BasketsView, checkout_cancel
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^orders/$', OrdersView.as_view(), name="orders"),
|
||||
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"),
|
||||
)
|
||||
|
||||
@@ -3,31 +3,31 @@ import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_202_ACCEPTED, HTTP_409_CONFLICT
|
||||
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.constants import OrderStatus, Messages
|
||||
from commerce.exceptions import ApiError, InvalidConfigurationError
|
||||
from commerce.constants import Messages
|
||||
from commerce.exceptions import ApiError, InvalidConfigurationError, 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 student.models import CourseEnrollment
|
||||
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
|
||||
from student.models import CourseEnrollment
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrdersView(APIView):
|
||||
""" Creates an order with a course seat and enrolls users. """
|
||||
class BasketsView(APIView):
|
||||
""" Creates a basket with a course seat and enrolls users. """
|
||||
|
||||
# LMS utilizes User.user_is_active to indicate email verification, not whether an account is active. Sigh!
|
||||
authentication_classes = (SessionAuthenticationAllowInactiveUser,)
|
||||
@@ -63,7 +63,7 @@ class OrdersView(APIView):
|
||||
|
||||
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Attempt to create the order and enroll the user.
|
||||
Attempt to create the basket and enroll the user.
|
||||
"""
|
||||
user = request.user
|
||||
valid, course_key, error = self._is_data_valid(request)
|
||||
@@ -103,28 +103,31 @@ class OrdersView(APIView):
|
||||
|
||||
# Make the API call
|
||||
try:
|
||||
order_number, order_status, _body = api.create_order(user, honor_mode.sku)
|
||||
if order_status == OrderStatus.COMPLETE:
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=order_number)
|
||||
response_data = api.create_basket(
|
||||
user,
|
||||
honor_mode.sku,
|
||||
payment_processor="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.
|
||||
return JsonResponse(payment_data)
|
||||
elif response_data['order']:
|
||||
# the order was completed immediately because there was no charge.
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=response_data['order']['number'])
|
||||
log.debug(msg)
|
||||
return DetailResponse(msg)
|
||||
else:
|
||||
# TODO Before this functionality is fully rolled-out, this branch should be updated to NOT enroll the
|
||||
# user. Enrollments must be initiated by the E-Commerce API only.
|
||||
# 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'Order %(order_number)s was received with %(status)s status. Expected %(complete_status)s. ' \
|
||||
u'User %(username)s was enrolled in %(course_id)s by LMS.'
|
||||
msg_kwargs = {
|
||||
'order_number': order_number,
|
||||
'status': order_status,
|
||||
'complete_status': OrderStatus.COMPLETE,
|
||||
'username': user.username,
|
||||
'course_id': course_id,
|
||||
}
|
||||
log.error(msg, msg_kwargs)
|
||||
|
||||
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=order_number)
|
||||
return DetailResponse(msg, status=HTTP_202_ACCEPTED)
|
||||
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)
|
||||
|
||||
@@ -918,24 +918,27 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
"""Mocks calls to EcommerceAPI.get_order. """
|
||||
patch = None
|
||||
|
||||
ORDER = copy.deepcopy(EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY)
|
||||
ORDER['total_excl_tax'] = 40.0
|
||||
ORDER['currency'] = 'USD'
|
||||
ORDER['sources'] = [{'transactions': [
|
||||
{'date_created': '2015-04-07 17:59:06.274587+00:00'},
|
||||
{'date_created': '2015-04-08 13:33:06.150000+00:00'},
|
||||
{'date_created': '2015-04-09 10:45:06.200000+00:00'},
|
||||
]}]
|
||||
ORDER['billing_address'] = {
|
||||
'first_name': 'Philip',
|
||||
'last_name': 'Fry',
|
||||
'line1': 'Robot Arms Apts',
|
||||
'line2': '22 Robot Street',
|
||||
'line4': 'New New York',
|
||||
'state': 'NY',
|
||||
'postcode': '11201',
|
||||
'country': {
|
||||
'display_name': 'United States',
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -58,4 +58,4 @@ class TestProfEdVerification(ModuleStoreTestCase):
|
||||
|
||||
# On the first page of the flow, verify that there's a button allowing the user
|
||||
# to proceed to the payment processor; this is the only action the user is allowed to take.
|
||||
self.assertContains(resp, 'pay_button')
|
||||
self.assertContains(resp, 'payment-button')
|
||||
|
||||
@@ -52,6 +52,8 @@ def mock_render_to_response(*args, **kwargs):
|
||||
|
||||
render_mock = Mock(side_effect=mock_render_to_response)
|
||||
|
||||
PAYMENT_DATA_KEYS = {'payment_processor_name', 'payment_page_url', 'payment_form_data'}
|
||||
|
||||
|
||||
class StartView(TestCase):
|
||||
def start_url(self, course_id=""):
|
||||
@@ -849,166 +851,159 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.assertEqual(response_dict['course_name'], mode_display_name)
|
||||
|
||||
|
||||
class TestCreateOrder(EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
class CheckoutTestMixin(object):
|
||||
"""
|
||||
Tests for the create order view.
|
||||
Mixin implementing test methods that should behave identically regardless
|
||||
of which backend is used (shoppingcart or ecommerce service). Subclasses
|
||||
immediately follow for each backend, which inherit from TestCase and
|
||||
define methods needed to customize test parameters, and patch the
|
||||
appropriate checkout method.
|
||||
|
||||
Though the view endpoint under test is named 'create_order' for backward-
|
||||
compatibility, the effect of using this endpoint is to choose a specific product
|
||||
(i.e. course mode) and trigger immediate checkout.
|
||||
"""
|
||||
def setUp(self):
|
||||
""" Create a user and course. """
|
||||
super(TestCreateOrder, self).setUp()
|
||||
super(CheckoutTestMixin, self).setUp()
|
||||
|
||||
self.user = UserFactory.create(username="test", password="test")
|
||||
self.course = CourseFactory.create()
|
||||
for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)):
|
||||
# Set SKU to empty string to ensure view knows how to handle such values
|
||||
CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku='')
|
||||
CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku=self.make_sku())
|
||||
self.client.login(username="test", password="test")
|
||||
|
||||
def _post(self, data):
|
||||
def _assert_checked_out(
|
||||
self,
|
||||
post_params,
|
||||
patched_create_order,
|
||||
expected_course_key,
|
||||
expected_mode_slug,
|
||||
expected_status_code=200
|
||||
):
|
||||
"""
|
||||
POST to the view being tested and return the response.
|
||||
DRY helper.
|
||||
|
||||
Ensures that checkout functions were invoked as
|
||||
expected during execution of the create_order endpoint.
|
||||
"""
|
||||
url = reverse('verify_student_create_order')
|
||||
return self.client.post(url, data)
|
||||
|
||||
def test_create_order_already_verified(self):
|
||||
# Verify the student so we don't need to submit photos
|
||||
self._verify_student()
|
||||
post_params.setdefault('processor', None)
|
||||
response = self.client.post(reverse('verify_student_create_order'), post_params)
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
if expected_status_code == 200:
|
||||
# ensure we called checkout at all
|
||||
self.assertTrue(patched_create_order.called)
|
||||
# ensure checkout args were correct
|
||||
args = self._get_checkout_args(patched_create_order)
|
||||
self.assertEqual(args['user'], self.user)
|
||||
self.assertEqual(args['course_key'], expected_course_key)
|
||||
self.assertEqual(args['course_mode'].slug, expected_mode_slug)
|
||||
# ensure response data was correct
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(set(data.keys()), PAYMENT_DATA_KEYS)
|
||||
else:
|
||||
self.assertFalse(patched_create_order.called)
|
||||
|
||||
def test_create_order(self, patched_create_order):
|
||||
# Create an order
|
||||
params = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'contribution': 100
|
||||
'contribution': 100,
|
||||
}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the information will be sent to the correct callback URL
|
||||
# (configured by test settings)
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data['override_custom_receipt_page'], "http://testserver/shoppingcart/postpay_callback/")
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
self.assertEqual(data['merchant_defined_data1'], unicode(self.course.id))
|
||||
self.assertEqual(data['merchant_defined_data2'], "verified")
|
||||
|
||||
def test_create_order_already_verified_prof_ed(self):
|
||||
# Verify the student so we don't need to submit photos
|
||||
self._verify_student()
|
||||
self._assert_checked_out(params, patched_create_order, self.course.id, 'verified')
|
||||
|
||||
def test_create_order_prof_ed(self, patched_create_order):
|
||||
# Create a prof ed course
|
||||
course = CourseFactory.create()
|
||||
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
|
||||
|
||||
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10, sku=self.make_sku())
|
||||
# Create an order for a prof ed course
|
||||
params = {'course_id': unicode(course.id)}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data['merchant_defined_data1'], unicode(course.id))
|
||||
self.assertEqual(data['merchant_defined_data2'], "professional")
|
||||
|
||||
def test_create_order_for_no_id_professional(self):
|
||||
self._assert_checked_out(params, patched_create_order, course.id, 'professional')
|
||||
|
||||
def test_create_order_no_id_professional(self, patched_create_order):
|
||||
# Create a no-id-professional ed course
|
||||
course = CourseFactory.create()
|
||||
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10)
|
||||
|
||||
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10, sku=self.make_sku())
|
||||
# Create an order for a prof ed course
|
||||
params = {'course_id': unicode(course.id)}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data['merchant_defined_data1'], unicode(course.id))
|
||||
self.assertEqual(data['merchant_defined_data2'], "no-id-professional")
|
||||
|
||||
def test_create_order_for_multiple_paid_modes(self):
|
||||
self._assert_checked_out(params, patched_create_order, course.id, 'no-id-professional')
|
||||
|
||||
def test_create_order_for_multiple_paid_modes(self, patched_create_order):
|
||||
# Create a no-id-professional ed course
|
||||
course = CourseFactory.create()
|
||||
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10)
|
||||
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
|
||||
|
||||
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10, sku=self.make_sku())
|
||||
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10, sku=self.make_sku())
|
||||
# Create an order for a prof ed course
|
||||
params = {'course_id': unicode(course.id)}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data['merchant_defined_data1'], unicode(course.id))
|
||||
self.assertEqual(data['merchant_defined_data2'], "no-id-professional")
|
||||
|
||||
def test_create_order_set_donation_amount(self):
|
||||
# Verify the student so we don't need to submit photos
|
||||
self._verify_student()
|
||||
# TODO jsa - is this the intended behavior?
|
||||
self._assert_checked_out(params, patched_create_order, course.id, 'no-id-professional')
|
||||
|
||||
def test_create_order_bad_donation_amount(self, patched_create_order):
|
||||
# Create an order
|
||||
params = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'contribution': '1.23'
|
||||
'contribution': '99.9'
|
||||
}
|
||||
self._post(params)
|
||||
self._assert_checked_out(params, patched_create_order, None, None, expected_status_code=400)
|
||||
|
||||
# Verify that the client's session contains the new donation amount
|
||||
self.assertNotIn('donation_for_course', self.client.session)
|
||||
def test_create_order_good_donation_amount(self, patched_create_order):
|
||||
# Create an order
|
||||
params = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'contribution': '100.0'
|
||||
}
|
||||
self._assert_checked_out(params, patched_create_order, self.course.id, 'verified')
|
||||
|
||||
def _verify_student(self):
|
||||
""" Simulate that the student's identity has already been verified. """
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
def test_create_order_with_ecommerce_api(self):
|
||||
""" Verifies that the view communicates with the E-Commerce API to create orders. """
|
||||
# Keep track of the original number of orders to verify the old code is not being called.
|
||||
order_count = Order.objects.count()
|
||||
|
||||
# Add SKU to CourseModes
|
||||
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
|
||||
course_mode.sku = uuid4().hex.decode('ascii')
|
||||
course_mode.save()
|
||||
|
||||
# Mock the E-Commerce Service response
|
||||
with self.mock_create_order():
|
||||
self._verify_student()
|
||||
params = {'course_id': unicode(self.course.id), 'contribution': 100}
|
||||
response = self._post(params)
|
||||
|
||||
# Verify the response is correct.
|
||||
def test_old_clients(self, patched_create_order):
|
||||
# ensure the response to a request from a stale js client is modified so as
|
||||
# not to break behavior in the browser.
|
||||
# (XCOM-214) remove after release.
|
||||
expected_payment_data = EcommerceApiTestMixin.PAYMENT_DATA.copy()
|
||||
expected_payment_data['payment_form_data'].update({'foo': 'bar'})
|
||||
patched_create_order.return_value = expected_payment_data
|
||||
# there is no 'processor' parameter in the post payload, so the response should only contain payment form data.
|
||||
params = {'course_id': unicode(self.course.id), 'contribution': 100}
|
||||
response = self.client.post(reverse('verify_student_create_order'), params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response['Content-Type'], 'application/json')
|
||||
self.assertEqual(json.loads(response.content), self.ECOMMERCE_API_SUCCESSFUL_BODY['payment_parameters'])
|
||||
self.assertTrue(patched_create_order.called)
|
||||
# ensure checkout args were correct
|
||||
args = self._get_checkout_args(patched_create_order)
|
||||
self.assertEqual(args['user'], self.user)
|
||||
self.assertEqual(args['course_key'], self.course.id)
|
||||
self.assertEqual(args['course_mode'].slug, 'verified')
|
||||
# ensure response data was correct
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data, {'foo': 'bar'})
|
||||
|
||||
# Verify old code is not called (e.g. no Order object created in LMS)
|
||||
self.assertEqual(order_count, Order.objects.count())
|
||||
|
||||
def _add_course_mode_skus(self):
|
||||
""" Add SKUs to the CourseMode objects for self.course. """
|
||||
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
|
||||
course_mode.sku = uuid4().hex.decode('ascii')
|
||||
course_mode.save()
|
||||
@patch('verify_student.views.checkout_with_shoppingcart', return_value=EcommerceApiTestMixin.PAYMENT_DATA)
|
||||
class TestCreateOrderShoppingCart(CheckoutTestMixin, ModuleStoreTestCase):
|
||||
""" Test view behavior when the shoppingcart is used. """
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
def test_create_order_with_ecommerce_api_errors(self):
|
||||
"""
|
||||
Verifies that the view communicates with the E-Commerce API to create orders, and handles errors
|
||||
appropriately.
|
||||
"""
|
||||
self._add_course_mode_skus()
|
||||
def make_sku(self):
|
||||
""" Checkout is handled by shoppingcart when the course mode's sku is empty. """
|
||||
return ''
|
||||
|
||||
with self.mock_create_order(side_effect=ApiError):
|
||||
self._verify_student()
|
||||
params = {'course_id': unicode(self.course.id), 'contribution': 100}
|
||||
self.assertRaises(ApiError, self._post, params)
|
||||
def _get_checkout_args(self, patched_create_order):
|
||||
""" Assuming patched_create_order was called, return a mapping containing the call arguments."""
|
||||
return dict(zip(('request', 'user', 'course_key', 'course_mode', 'amount'), patched_create_order.call_args[0]))
|
||||
|
||||
|
||||
@override_settings(
|
||||
ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY
|
||||
)
|
||||
@patch('verify_student.views.checkout_with_ecommerce_service', return_value=EcommerceApiTestMixin.PAYMENT_DATA)
|
||||
class TestCreateOrderEcommerceService(CheckoutTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
""" Test view behavior when the ecommerce service is used. """
|
||||
|
||||
def make_sku(self):
|
||||
""" Checkout is handled by the ecommerce service when the course mode's sku is nonempty. """
|
||||
return uuid4().hex.decode('ascii')
|
||||
|
||||
def _get_checkout_args(self, patched_create_order):
|
||||
""" Assuming patched_create_order was called, return a mapping containing the call arguments."""
|
||||
return dict(zip(('user', 'course_key', 'course_mode', 'processor'), patched_create_order.call_args[0]))
|
||||
|
||||
|
||||
class TestCreateOrderView(ModuleStoreTestCase):
|
||||
@@ -1099,7 +1094,7 @@ class TestCreateOrderView(ModuleStoreTestCase):
|
||||
photo_id_image=self.IMAGE_DATA
|
||||
)
|
||||
json_response = json.loads(response.content)
|
||||
self.assertIsNotNone(json_response.get('orderNumber'))
|
||||
self.assertIsNotNone(json_response['payment_form_data'].get('orderNumber')) # TODO not canonical
|
||||
|
||||
# Verify that the order exists and is configured correctly
|
||||
order = Order.objects.get(user=self.user)
|
||||
@@ -1135,7 +1130,8 @@ class TestCreateOrderView(ModuleStoreTestCase):
|
||||
url = reverse('verify_student_create_order')
|
||||
data = {
|
||||
'contribution': contribution,
|
||||
'course_id': course_id
|
||||
'course_id': course_id,
|
||||
'processor': None,
|
||||
}
|
||||
|
||||
if face_image is not None:
|
||||
@@ -1149,9 +1145,9 @@ class TestCreateOrderView(ModuleStoreTestCase):
|
||||
if expect_status_code == 200:
|
||||
json_response = json.loads(response.content)
|
||||
if expect_success:
|
||||
self.assertTrue(json_response.get('success'))
|
||||
self.assertEqual(set(json_response.keys()), PAYMENT_DATA_KEYS)
|
||||
else:
|
||||
self.assertFalse(json_response.get('success'))
|
||||
self.assertFalse(json_response['success'])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -380,6 +380,14 @@ class PayAndVerifyView(View):
|
||||
# Determine the photo verification status
|
||||
verification_good_until = self._verification_valid_until(request.user)
|
||||
|
||||
# get available payment processors
|
||||
if unexpired_paid_course_mode.sku:
|
||||
# transaction will be conducted via ecommerce service
|
||||
processors = EcommerceAPI().get_processors(request.user)
|
||||
else:
|
||||
# transaction will be conducted using legacy shopping cart
|
||||
processors = [settings.CC_PROCESSOR_NAME]
|
||||
|
||||
# Render the top-level page
|
||||
context = {
|
||||
'contribution_amount': contribution_amount,
|
||||
@@ -393,7 +401,7 @@ class PayAndVerifyView(View):
|
||||
'is_active': json.dumps(request.user.is_active),
|
||||
'message_key': message,
|
||||
'platform_name': settings.PLATFORM_NAME,
|
||||
'purchase_endpoint': get_purchase_endpoint(),
|
||||
'processors': processors,
|
||||
'requirements': requirements,
|
||||
'user_full_name': full_name,
|
||||
'verification_deadline': (
|
||||
@@ -644,26 +652,59 @@ class PayAndVerifyView(View):
|
||||
return (has_paid, bool(is_active))
|
||||
|
||||
|
||||
def create_order_with_ecommerce_service(user, course_key, course_mode): # pylint: disable=invalid-name
|
||||
""" Create a new order using the E-Commerce API. """
|
||||
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()
|
||||
# Make an API call to create the order and retrieve the results
|
||||
_order_number, _order_status, data = api.create_order(user, course_mode.sku)
|
||||
|
||||
response_data = api.create_basket(user, course_mode.sku, processor)
|
||||
# Pass the payment parameters directly from the API response.
|
||||
return HttpResponse(json.dumps(data['payment_parameters']), content_type='application/json')
|
||||
return response_data.get('payment_data')
|
||||
except ApiError:
|
||||
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)
|
||||
raise
|
||||
|
||||
|
||||
def checkout_with_shoppingcart(request, user, course_key, course_mode, amount):
|
||||
""" Create an order and trigger checkout using shoppingcart."""
|
||||
cart = Order.get_cart_for_user(user)
|
||||
cart.clear()
|
||||
enrollment_mode = course_mode.slug
|
||||
CertificateItem.add_to_order(cart, course_key, amount, enrollment_mode)
|
||||
|
||||
# Change the order's status so that we don't accidentally modify it later.
|
||||
# We need to do this to ensure that the parameters we send to the payment system
|
||||
# match what we store in the database.
|
||||
# (Ordinarily we would do this client-side when the user submits the form, but since
|
||||
# the JavaScript on this page does that immediately, we make the change here instead.
|
||||
# This avoids a second AJAX call and some additional complication of the JavaScript.)
|
||||
# If a user later re-enters the verification / payment flow, she will create a new order.
|
||||
cart.start_purchase()
|
||||
|
||||
callback_url = request.build_absolute_uri(
|
||||
reverse("shoppingcart.views.postpay_callback")
|
||||
)
|
||||
|
||||
payment_data = {
|
||||
'payment_processor_name': settings.CC_PROCESSOR_NAME,
|
||||
'payment_page_url': get_purchase_endpoint(),
|
||||
'payment_form_data': get_signed_purchase_params(
|
||||
cart,
|
||||
callback_url=callback_url,
|
||||
extra_data=[unicode(course_key), course_mode.slug]
|
||||
),
|
||||
}
|
||||
return payment_data
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def create_order(request):
|
||||
"""
|
||||
Submit PhotoVerification and create a new Order for this verified cert
|
||||
This endpoint is named 'create_order' for backward compatibility, but its
|
||||
actual use is to add a single product to the user's cart and request
|
||||
immediate checkout.
|
||||
"""
|
||||
# Only submit photos if photo data is provided by the client.
|
||||
# TODO (ECOM-188): Once the A/B test of decoupling verified / payment
|
||||
@@ -724,35 +765,23 @@ def create_order(request):
|
||||
return HttpResponseBadRequest(_("No selected price or selected price is below minimum."))
|
||||
|
||||
if current_mode.sku:
|
||||
return create_order_with_ecommerce_service(request.user, course_id, current_mode)
|
||||
# if request.POST doesn't contain 'processor' then the service's default payment processor will be used.
|
||||
payment_data = checkout_with_ecommerce_service(
|
||||
request.user,
|
||||
course_id,
|
||||
current_mode,
|
||||
request.POST.get('processor')
|
||||
)
|
||||
else:
|
||||
payment_data = checkout_with_shoppingcart(request, request.user, course_id, current_mode, amount)
|
||||
|
||||
# I know, we should check this is valid. All kinds of stuff missing here
|
||||
cart = Order.get_cart_for_user(request.user)
|
||||
cart.clear()
|
||||
enrollment_mode = current_mode.slug
|
||||
CertificateItem.add_to_order(cart, course_id, amount, enrollment_mode)
|
||||
|
||||
# Change the order's status so that we don't accidentally modify it later.
|
||||
# We need to do this to ensure that the parameters we send to the payment system
|
||||
# match what we store in the database.
|
||||
# (Ordinarily we would do this client-side when the user submits the form, but since
|
||||
# the JavaScript on this page does that immediately, we make the change here instead.
|
||||
# This avoids a second AJAX call and some additional complication of the JavaScript.)
|
||||
# If a user later re-enters the verification / payment flow, she will create a new order.
|
||||
cart.start_purchase()
|
||||
|
||||
callback_url = request.build_absolute_uri(
|
||||
reverse("shoppingcart.views.postpay_callback")
|
||||
)
|
||||
|
||||
params = get_signed_purchase_params(
|
||||
cart,
|
||||
callback_url=callback_url,
|
||||
extra_data=[unicode(course_id), current_mode.slug]
|
||||
)
|
||||
|
||||
params['success'] = True
|
||||
return HttpResponse(json.dumps(params), content_type="text/json")
|
||||
if 'processor' not in request.POST:
|
||||
# (XCOM-214) To be removed after release.
|
||||
# the absence of this key in the POST payload indicates that the request was initiated from
|
||||
# a stale js client, which expects a response containing only the 'payment_form_data' part of
|
||||
# the payment data result.
|
||||
payment_data = payment_data['payment_form_data']
|
||||
return HttpResponse(json.dumps(payment_data), content_type="application/json")
|
||||
|
||||
|
||||
@require_POST
|
||||
|
||||
@@ -4,7 +4,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
|
||||
describe("Photo Verification", function () {
|
||||
|
||||
beforeEach(function () {
|
||||
setFixtures('<div id="order-error" style="display: none;"></div><input type="radio" name="contribution" value="35" id="contribution-35" checked="checked"><input type="radio" id="contribution-other" name="contribution" value=""><input type="text" size="9" name="contribution-other-amt" id="contribution-other-amt" value="30"><img id="face_image" src="src="data:image/png;base64,dummy"><img id="photo_id_image" src="src="data:image/png;base64,dummy"><button id="pay_button">pay button</button>');
|
||||
setFixtures('<div id="order-error" style="display: none;"></div><input type="radio" name="contribution" value="35" id="contribution-35" checked="checked"><input type="radio" id="contribution-other" name="contribution" value=""><input type="text" size="9" name="contribution-other-amt" id="contribution-other-amt" value="30"><img id="face_image" src="src="data:image/png;base64,dummy"><img id="photo_id_image" src="src="data:image/png;base64,dummy"><button class="payment-button">pay button</button>');
|
||||
});
|
||||
|
||||
it('retake photo', function () {
|
||||
@@ -27,7 +27,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
|
||||
});
|
||||
submitToPaymentProcessing();
|
||||
expect(window.submitForm).toHaveBeenCalled();
|
||||
expect($("#pay_button")).toHaveClass("is-disabled");
|
||||
expect($(".payment-button")).toHaveClass("is-disabled");
|
||||
});
|
||||
|
||||
it('Error during process', function () {
|
||||
@@ -44,7 +44,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
|
||||
expect(window.showSubmissionError).toHaveBeenCalled();
|
||||
|
||||
// make sure the button isn't disabled
|
||||
expect($("#pay_button")).not.toHaveClass("is-disabled");
|
||||
expect($(".payment-button")).not.toHaveClass("is-disabled");
|
||||
|
||||
// but also make sure that it was disabled during the ajax call
|
||||
expect($.fn.addClass).toHaveBeenCalledWith("is-disabled");
|
||||
|
||||
@@ -5,7 +5,7 @@ define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'],
|
||||
describe( 'edx.student.account.EnrollmentInterface', function() {
|
||||
|
||||
var COURSE_KEY = 'edX/DemoX/Fall',
|
||||
ENROLL_URL = '/commerce/orders/',
|
||||
ENROLL_URL = '/commerce/baskets/',
|
||||
FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/',
|
||||
EMBARGO_MSG_URL = '/embargo/blocked-message/enrollment/default/';
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ define([
|
||||
|
||||
describe( 'edx.verify_student.MakePaymentStepView', function() {
|
||||
|
||||
var PAYMENT_URL = "/pay";
|
||||
|
||||
var PAYMENT_PARAMS = {
|
||||
orderId: "test-order",
|
||||
signature: "abcd1234"
|
||||
@@ -21,7 +19,7 @@ define([
|
||||
var STEP_DATA = {
|
||||
minPrice: "12",
|
||||
currency: "usd",
|
||||
purchaseEndpoint: PAYMENT_URL,
|
||||
processors: ["test-payment-processor"],
|
||||
courseKey: "edx/test/test",
|
||||
courseModeSlug: 'verified'
|
||||
};
|
||||
@@ -50,15 +48,16 @@ define([
|
||||
};
|
||||
|
||||
var expectPaymentButtonEnabled = function( isEnabled ) {
|
||||
var appearsDisabled = $( '#pay_button' ).hasClass( 'is-disabled' ),
|
||||
isDisabled = $( '#pay_button' ).prop( 'disabled' );
|
||||
var el = $( '.payment-button'),
|
||||
appearsDisabled = el.hasClass( 'is-disabled' ),
|
||||
isDisabled = el.prop( 'disabled' );
|
||||
|
||||
expect( !appearsDisabled ).toEqual( isEnabled );
|
||||
expect( !isDisabled ).toEqual( isEnabled );
|
||||
};
|
||||
|
||||
var expectPaymentDisabledBecauseInactive = function() {
|
||||
var payButton = $( '#pay_button' );
|
||||
var payButton = $( '.payment_button' );
|
||||
|
||||
// Payment button should be hidden
|
||||
expect( payButton.length ).toEqual(0);
|
||||
@@ -67,21 +66,22 @@ define([
|
||||
var goToPayment = function( requests, kwargs ) {
|
||||
var params = {
|
||||
contribution: kwargs.amount || "",
|
||||
course_id: kwargs.courseId || ""
|
||||
course_id: kwargs.courseId || "",
|
||||
processor: kwargs.processor || ""
|
||||
};
|
||||
|
||||
// Click the "go to payment" button
|
||||
$( '#pay_button' ).click();
|
||||
$( '.payment-button' ).click();
|
||||
|
||||
// Verify that the request was made to the server
|
||||
AjaxHelpers.expectRequest(
|
||||
requests, "POST", "/verify_student/create_order/",
|
||||
$.param( params )
|
||||
AjaxHelpers.expectPostRequest(
|
||||
requests, "/verify_student/create_order/", $.param( params )
|
||||
);
|
||||
|
||||
// Simulate the server response
|
||||
if ( kwargs.succeeds ) {
|
||||
AjaxHelpers.respondWithJson( requests, PAYMENT_PARAMS );
|
||||
// TODO put fixture responses in the right place
|
||||
AjaxHelpers.respondWithJson( requests, {payment_page_url: 'http://payment-page-url/', payment_form_data: {foo: 'bar'}} );
|
||||
} else {
|
||||
AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG );
|
||||
}
|
||||
@@ -95,7 +95,7 @@ define([
|
||||
|
||||
expect(form.serialize()).toEqual($.param(params));
|
||||
expect(form.attr('method')).toEqual("POST");
|
||||
expect(form.attr('action')).toEqual(PAYMENT_URL);
|
||||
expect(form.attr('action')).toEqual('http://payment-page-url/');
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
@@ -114,9 +114,10 @@ define([
|
||||
goToPayment( requests, {
|
||||
amount: STEP_DATA.minPrice,
|
||||
courseId: STEP_DATA.courseKey,
|
||||
processor: STEP_DATA.processors[0],
|
||||
succeeds: true
|
||||
});
|
||||
expectPaymentSubmitted( view, PAYMENT_PARAMS );
|
||||
expectPaymentSubmitted( view, {foo: 'bar'} );
|
||||
});
|
||||
|
||||
it( 'by default minimum price is selected if no suggested prices are given', function() {
|
||||
@@ -129,9 +130,10 @@ define([
|
||||
goToPayment( requests, {
|
||||
amount: STEP_DATA.minPrice,
|
||||
courseId: STEP_DATA.courseKey,
|
||||
processor: STEP_DATA.processors[0],
|
||||
succeeds: true
|
||||
});
|
||||
expectPaymentSubmitted( view, PAYMENT_PARAMS );
|
||||
expectPaymentSubmitted( view, {foo: 'bar'} );
|
||||
});
|
||||
|
||||
it( 'min price is always selected even if contribution amount is provided', function() {
|
||||
@@ -156,6 +158,7 @@ define([
|
||||
goToPayment( requests, {
|
||||
amount: STEP_DATA.minPrice,
|
||||
courseId: STEP_DATA.courseKey,
|
||||
processor: STEP_DATA.processors[0],
|
||||
succeeds: false
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ var edx = edx || {};
|
||||
edx.student.account.EnrollmentInterface = {
|
||||
|
||||
urls: {
|
||||
orders: '/commerce/orders/',
|
||||
baskets: '/commerce/baskets/',
|
||||
},
|
||||
|
||||
headers: {
|
||||
@@ -26,7 +26,7 @@ var edx = edx || {};
|
||||
data = JSON.stringify(data_obj);
|
||||
|
||||
$.ajax({
|
||||
url: this.urls.orders,
|
||||
url: this.urls.baskets,
|
||||
type: 'POST',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
data: data,
|
||||
|
||||
@@ -59,7 +59,7 @@ var edx = edx || {};
|
||||
function( price ) { return Boolean( price ); }
|
||||
),
|
||||
currency: el.data('course-mode-currency'),
|
||||
purchaseEndpoint: el.data('purchase-endpoint'),
|
||||
processors: el.data('processors'),
|
||||
verificationDeadline: el.data('verification-deadline'),
|
||||
courseModeSlug: el.data('course-mode-slug'),
|
||||
alreadyVerified: el.data('already-verified'),
|
||||
|
||||
@@ -69,7 +69,7 @@ function refereshPageMessage() {
|
||||
}
|
||||
|
||||
var submitToPaymentProcessing = function() {
|
||||
$("#pay_button").addClass('is-disabled').attr('aria-disabled', true);
|
||||
$(".payment-button").addClass('is-disabled').attr('aria-disabled', true);
|
||||
var contribution_input = $("input[name='contribution']:checked")
|
||||
var contribution = 0;
|
||||
if(contribution_input.attr('id') == 'contribution-other') {
|
||||
@@ -96,7 +96,7 @@ var submitToPaymentProcessing = function() {
|
||||
}
|
||||
},
|
||||
error:function(xhr,status,error) {
|
||||
$("#pay_button").removeClass('is-disabled').attr('aria-disabled', false);
|
||||
$(".payment-button").removeClass('is-disabled').attr('aria-disabled', false);
|
||||
showSubmissionError()
|
||||
}
|
||||
});
|
||||
@@ -290,7 +290,7 @@ function waitForFlashLoad(func, flash_object) {
|
||||
|
||||
$(document).ready(function() {
|
||||
$(".carousel-nav").addClass('sr');
|
||||
$("#pay_button").click(function(){
|
||||
$(".payment-button").click(function(){
|
||||
analytics.pageview("Payment Form");
|
||||
submitToPaymentProcessing();
|
||||
});
|
||||
@@ -306,7 +306,7 @@ $(document).ready(function() {
|
||||
// prevent browsers from keeping this button checked
|
||||
$("#confirm_pics_good").prop("checked", false)
|
||||
$("#confirm_pics_good").change(function() {
|
||||
$("#pay_button").toggleClass('disabled');
|
||||
$(".payment-button").toggleClass('disabled');
|
||||
$("#reverify_button").toggleClass('disabled');
|
||||
$("#midcourse_reverify_button").toggleClass('disabled');
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
var edx = edx || {};
|
||||
|
||||
(function( $, _, gettext ) {
|
||||
(function( $, _, gettext, interpolate_text ) {
|
||||
'use strict';
|
||||
|
||||
edx.verify_student = edx.verify_student || {};
|
||||
@@ -28,12 +28,45 @@ var edx = edx || {};
|
||||
};
|
||||
},
|
||||
|
||||
_getProductText: function( modeSlug, isUpgrade ) {
|
||||
switch ( modeSlug ) {
|
||||
case "professional":
|
||||
return gettext( "Professional Education Verified Certificate" );
|
||||
case "no-id-professional":
|
||||
return gettext( "Professional Education" );
|
||||
default:
|
||||
if ( isUpgrade ) {
|
||||
return gettext( "Verified Certificate upgrade" );
|
||||
} else {
|
||||
return gettext( "Verified Certificate" );
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_getPaymentButtonText: function(processorName) {
|
||||
if (processorName.toLowerCase().substr(0, 11)=='cybersource') {
|
||||
return gettext('Pay with Credit Card');
|
||||
} else {
|
||||
// This is mainly for testing as no other processors are supported right now.
|
||||
// Translators: 'processor' is the name of a third-party payment processing vendor (example: "PayPal")
|
||||
return interpolate_text(gettext('Pay with {processor}'), {processor: processorName});
|
||||
}
|
||||
},
|
||||
|
||||
_getPaymentButtonHtml: function(processorName) {
|
||||
var self = this;
|
||||
return _.template(
|
||||
'<a class="next action-primary payment-button" id="<%- name %>" tab-index="0"><%- text %></a> '
|
||||
)({name: processorName, text: self._getPaymentButtonText(processorName)});
|
||||
},
|
||||
|
||||
postRender: function() {
|
||||
var templateContext = this.templateContext(),
|
||||
hasVisibleReqs = _.some(
|
||||
templateContext.requirements,
|
||||
function( isVisible ) { return isVisible; }
|
||||
);
|
||||
),
|
||||
self = this;
|
||||
|
||||
// Track a virtual pageview, for easy funnel reconstruction.
|
||||
window.analytics.page( 'payment', this.templateName );
|
||||
@@ -59,25 +92,41 @@ var edx = edx || {};
|
||||
this.setPaymentEnabled( true );
|
||||
}
|
||||
|
||||
// render the name of the product being paid for
|
||||
$( 'div.payment-buttons span.product-name').append(
|
||||
self._getProductText( templateContext.courseModeSlug, templateContext.upgrade )
|
||||
);
|
||||
|
||||
// create a button for each payment processor
|
||||
_.each(templateContext.processors, function(processorName) {
|
||||
$( 'div.payment-buttons' ).append( self._getPaymentButtonHtml(processorName) );
|
||||
});
|
||||
|
||||
// Handle payment submission
|
||||
$( '#pay_button' ).on( 'click', _.bind( this.createOrder, this ) );
|
||||
$( '.payment-button' ).on( 'click', _.bind( this.createOrder, this ) );
|
||||
},
|
||||
|
||||
setPaymentEnabled: function( isEnabled ) {
|
||||
if ( _.isUndefined( isEnabled ) ) {
|
||||
isEnabled = true;
|
||||
}
|
||||
$( '#pay_button' )
|
||||
$( '.payment-button' )
|
||||
.toggleClass( 'is-disabled', !isEnabled )
|
||||
.prop( 'disabled', !isEnabled )
|
||||
.attr('aria-disabled', !isEnabled);
|
||||
},
|
||||
|
||||
createOrder: function() {
|
||||
// This function invokes the create_order endpoint. It will either create an order in
|
||||
// the lms' shoppingcart or a basket in Otto, depending on which backend the request course
|
||||
// mode is configured to use. In either case, the checkout process will be triggered,
|
||||
// and the expected response will consist of an appropriate payment processor endpoint for
|
||||
// redirection, along with parameters to be passed along in the request.
|
||||
createOrder: function(event) {
|
||||
var paymentAmount = this.getPaymentAmount(),
|
||||
postData = {
|
||||
'processor': event.target.id,
|
||||
'contribution': paymentAmount,
|
||||
'course_id': this.stepData.courseKey,
|
||||
'course_id': this.stepData.courseKey
|
||||
};
|
||||
|
||||
// Disable the payment button to prevent multiple submissions
|
||||
@@ -98,21 +147,21 @@ var edx = edx || {};
|
||||
|
||||
},
|
||||
|
||||
handleCreateOrderResponse: function( paymentParams ) {
|
||||
// At this point, the order has been created on the server,
|
||||
handleCreateOrderResponse: function( paymentData ) {
|
||||
// At this point, the basket has been created on the server,
|
||||
// and we've received signed payment parameters.
|
||||
// We need to dynamically construct a form using
|
||||
// these parameters, then submit it to the payment processor.
|
||||
// This will send the user to a hosted order page,
|
||||
// where she can enter credit card information.
|
||||
// This will send the user to an externally-hosted page
|
||||
// where she can proceed with payment.
|
||||
var form = $( '#payment-processor-form' );
|
||||
|
||||
$( 'input', form ).remove();
|
||||
|
||||
form.attr( 'action', this.stepData.purchaseEndpoint );
|
||||
form.attr( 'action', paymentData.payment_page_url );
|
||||
form.attr( 'method', 'POST' );
|
||||
|
||||
_.each( paymentParams, function( value, key ) {
|
||||
_.each( paymentData.payment_form_data, function( value, key ) {
|
||||
$('<input>').attr({
|
||||
type: 'hidden',
|
||||
name: key,
|
||||
@@ -200,4 +249,4 @@ var edx = edx || {};
|
||||
|
||||
});
|
||||
|
||||
})( jQuery, _, gettext );
|
||||
})( jQuery, _, gettext, interpolate_text );
|
||||
|
||||
@@ -50,3 +50,22 @@
|
||||
padding: ($baseline*1.5) $baseline;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// for verify_student/make_payment_step.underscore
|
||||
.payment-buttons {
|
||||
|
||||
.purchase {
|
||||
float: left;
|
||||
padding: ($baseline*.5) 0;
|
||||
|
||||
.product-info, .product-name, .price {
|
||||
@extend %t-ultrastrong;
|
||||
color: $m-blue-d3;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-button {
|
||||
float: right;
|
||||
@include margin-left( ($baseline/2) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,16 @@
|
||||
<% } %>
|
||||
|
||||
<% if ( isActive ) { %>
|
||||
<div class="nav-wizard is-ready center">
|
||||
<div class="payment-buttons nav-wizard is-ready center">
|
||||
<input type="hidden" name="contribution" value="<%- minPrice %>" />
|
||||
<a class="next action-primary" id="pay_button" tab-index="0">
|
||||
<%- gettext( "Continue to payment" ) %> ($<%- minPrice %>)
|
||||
</a>
|
||||
<div class="purchase">
|
||||
<p class="product-info"><span class="product-name"></span> <%- gettext( "price" ) %>: <span class="price">$<%- minPrice %></span></p>
|
||||
</div>
|
||||
<div class="pay-options">
|
||||
<%
|
||||
// payment buttons will go here
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ from verify_student.views import PayAndVerifyView
|
||||
data-course-mode-suggested-prices='${course_mode.suggested_prices}'
|
||||
data-course-mode-currency='${course_mode.currency}'
|
||||
data-contribution-amount='${contribution_amount}'
|
||||
data-purchase-endpoint='${purchase_endpoint}'
|
||||
data-processors='${json.dumps(processors)}'
|
||||
data-verification-deadline='${verification_deadline}'
|
||||
data-display-steps='${json.dumps(display_steps)}'
|
||||
data-current-step='${current_step}'
|
||||
|
||||
Reference in New Issue
Block a user