diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py
index 236322268e..98dc36b134 100644
--- a/lms/djangoapps/shoppingcart/tests/test_views.py
+++ b/lms/djangoapps/shoppingcart/tests/test_views.py
@@ -26,6 +26,7 @@ from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
+from util.date_utils import get_default_time_display
from shoppingcart.views import _can_download_report, _get_date_from_str
from shoppingcart.models import (
Order, CertificateItem, PaidCourseRegistration, CourseRegCodeItem,
@@ -66,6 +67,7 @@ MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, incl
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
+@ddt.ddt
class ShoppingCartViewsTests(ModuleStoreTestCase):
def setUp(self):
patcher = patch('student.models.tracker')
@@ -801,6 +803,103 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
self.assertEqual(context['order'], self.cart)
self.assertEqual(context['error_html'], 'ERROR_TEST!!!')
+ @ddt.data(0, 1)
+ def test_show_receipt_json(self, num_items):
+ # Create the correct number of items in the order
+ for __ in range(num_items):
+ CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor')
+ self.cart.purchase()
+
+ self.login_user()
+ url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id])
+ 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.cart.currency)
+ self.assertEqual(json_resp.get('purchase_datetime'), get_default_time_display(self.cart.purchase_time))
+ self.assertEqual(json_resp.get('total_cost'), self.cart.total_cost)
+ self.assertEqual(json_resp.get('status'), "purchased")
+ self.assertEqual(json_resp.get('billed_to'), {
+ 'first_name': self.cart.bill_to_first,
+ 'last_name': self.cart.bill_to_last,
+ 'street1': self.cart.bill_to_street1,
+ 'street2': self.cart.bill_to_street2,
+ 'city': self.cart.bill_to_city,
+ 'state': self.cart.bill_to_state,
+ 'postal_code': self.cart.bill_to_postalcode,
+ 'country': self.cart.bill_to_country
+ })
+
+ self.assertEqual(len(json_resp.get('items')), num_items)
+ for item in json_resp.get('items'):
+ self.assertEqual(item, {
+ 'unit_cost': 40,
+ 'quantity': 1,
+ 'line_cost': 40,
+ 'line_desc': 'Honor Code Certificate for course Test Course'
+ })
+
+ def test_show_receipt_json_multiple_items(self):
+ # Two different item types
+ PaidCourseRegistration.add_to_order(self.cart, self.course_key)
+ CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor')
+ self.cart.purchase()
+
+ self.login_user()
+ url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id])
+ 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('total_cost'), self.cart.total_cost)
+
+ items = json_resp.get('items')
+ self.assertEqual(len(items), 2)
+ self.assertEqual(items[0], {
+ 'unit_cost': 40,
+ 'quantity': 1,
+ 'line_cost': 40,
+ 'line_desc': 'Registration for Course: Robot Super Course'
+ })
+ self.assertEqual(items[1], {
+ 'unit_cost': 40,
+ 'quantity': 1,
+ 'line_cost': 40,
+ 'line_desc': 'Honor Code Certificate for course Test Course'
+ })
+
+ def test_receipt_json_refunded(self):
+ mock_enrollment = Mock()
+ mock_enrollment.refundable.side_effect = lambda: True
+ mock_enrollment.course_id = self.verified_course_key
+ mock_enrollment.user = self.user
+
+ CourseMode.objects.create(
+ course_id=self.verified_course_key,
+ mode_slug="verified",
+ mode_display_name="verified cert",
+ min_price=self.cost
+ )
+
+ cert = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'verified')
+ self.cart.purchase()
+ cert.refund_cert_callback(course_enrollment=mock_enrollment)
+
+ self.login_user()
+ url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id])
+ resp = self.client.get(url, HTTP_ACCEPT="application/json")
+ self.assertEqual(resp.status_code, 200)
+
+ json_resp = json.loads(resp.content)
+ self.assertEqual(json_resp.get('status'), 'refunded')
+
def test_show_receipt_404s(self):
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor')
diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py
index c3288384ef..2286d0b12c 100644
--- a/lms/djangoapps/shoppingcart/views.py
+++ b/lms/djangoapps/shoppingcart/views.py
@@ -14,6 +14,7 @@ from django.views.decorators.http import require_POST, require_http_methods
from django.core.urlresolvers import reverse
from django.views.decorators.csrf import csrf_exempt
from util.bad_request_rate_limiter import BadRequestRateLimiter
+from util.date_utils import get_default_time_display
from django.contrib.auth.decorators import login_required
from microsite_configuration import microsite
from edxmako.shortcuts import render_to_response
@@ -655,15 +656,76 @@ def show_receipt(request, ordernum):
Displays a receipt for a particular order.
404 if order is not yet purchased or request.user != order.user
"""
-
try:
order = Order.objects.get(id=ordernum)
except Order.DoesNotExist:
raise Http404('Order not found!')
- if order.user != request.user or order.status != 'purchased':
+ if order.user != request.user or order.status not in ['purchased', 'refunded']:
raise Http404('Order not found!')
+ if 'application/json' in request.META.get('HTTP_ACCEPT', ""):
+ return _show_receipt_json(order)
+ else:
+ return _show_receipt_html(request, order)
+
+
+def _show_receipt_json(order):
+ """Render the receipt page as JSON.
+
+ The included information is deliberately minimal:
+ as much as possible, the included information should
+ be common to *all* order items, so the client doesn't
+ need to handle different item types differently.
+
+ Arguments:
+ request (HttpRequest): The request for the receipt.
+ order (Order): The order model to display.
+
+ Returns:
+ HttpResponse
+
+ """
+ order_info = {
+ 'orderNum': order.id,
+ 'currency': order.currency,
+ 'status': order.status,
+ 'purchase_datetime': get_default_time_display(order.purchase_time) if order.purchase_time else None,
+ 'billed_to': {
+ 'first_name': order.bill_to_first,
+ 'last_name': order.bill_to_last,
+ 'street1': order.bill_to_street1,
+ 'street2': order.bill_to_street2,
+ 'city': order.bill_to_city,
+ 'state': order.bill_to_state,
+ 'postal_code': order.bill_to_postalcode,
+ 'country': order.bill_to_country,
+ },
+ 'total_cost': order.total_cost,
+ 'items': [
+ {
+ 'quantity': item.qty,
+ 'unit_cost': item.unit_cost,
+ 'line_cost': item.line_cost,
+ 'line_desc': item.line_desc
+ }
+ for item in OrderItem.objects.filter(order=order).select_subclasses()
+ ]
+ }
+ return JsonResponse(order_info)
+
+
+def _show_receipt_html(request, order):
+ """Render the receipt page as HTML.
+
+ Arguments:
+ request (HttpRequest): The request for the receipt.
+ order (Order): The order model to display.
+
+ Returns:
+ HttpResponse
+
+ """
order_items = OrderItem.objects.filter(order=order).select_subclasses()
shoppingcart_items = []
course_names_list = []
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 6b666bd35d..10a29b7dda 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1010,6 +1010,7 @@ courseware_js = (
base_vendor_js = [
'js/vendor/jquery.min.js',
'js/vendor/jquery.cookie.js',
+ 'js/vendor/url.min.js',
'js/vendor/underscore-min.js',
'js/vendor/require.js',
'js/RequireJS-namespace-undefine.js',
diff --git a/lms/static/js/verify_student/pay_and_verify.js b/lms/static/js/verify_student/pay_and_verify.js
index 63410bb480..6518b11f56 100644
--- a/lms/static/js/verify_student/pay_and_verify.js
+++ b/lms/static/js/verify_student/pay_and_verify.js
@@ -56,6 +56,11 @@ var edx = edx || {};
'review-photos-step': {
fullName: el.data('full-name'),
platformName: el.data('platform-name')
+ },
+ 'enrollment-confirmation-step': {
+ courseName: el.data('course-name'),
+ courseStartDate: el.data('course-start-date'),
+ coursewareUrl: el.data('courseware-url')
}
}
}).render();
diff --git a/lms/static/js/verify_student/views/pay_and_verify_view.js b/lms/static/js/verify_student/views/pay_and_verify_view.js
index 2b78894718..554a0bb151 100644
--- a/lms/static/js/verify_student/views/pay_and_verify_view.js
+++ b/lms/static/js/verify_student/views/pay_and_verify_view.js
@@ -30,18 +30,17 @@ var edx = edx || {};
this.errorModel = obj.errorModel || {};
this.displaySteps = obj.displaySteps || [];
- // Determine which step we're starting on
- // Depending on how the user enters the flow,
- // this could be anywhere in the sequence of steps.
- this.currentStepIndex = _.indexOf(
- _.pluck( this.displaySteps, 'name' ),
- obj.currentStep
- );
-
this.progressView = new edx.verify_student.ProgressView({
el: this.el,
displaySteps: this.displaySteps,
- currentStepIndex: this.currentStepIndex
+
+ // Determine which step we're starting on
+ // Depending on how the user enters the flow,
+ // this could be anywhere in the sequence of steps.
+ currentStepIndex: _.indexOf(
+ _.pluck( this.displaySteps, 'name' ),
+ obj.currentStep
+ )
});
this.initializeStepViews( obj.stepInfo );
@@ -140,29 +139,21 @@ var edx = edx || {};
// underscore template.
// When the view is rendered, it will overwrite the existing
// step in the DOM.
- stepName = this.displaySteps[ this.currentStepIndex ].name;
+ stepName = this.displaySteps[ this.progressView.currentStepIndex ].name;
stepView = this.subviews[ stepName ];
stepView.el = stepEl;
stepView.render();
},
nextStep: function() {
- this.currentStepIndex = Math.min( this.currentStepIndex + 1, this.displaySteps.length - 1 );
+ this.progressView.nextStep();
this.render();
},
goToStep: function( stepName ) {
- var stepIndex = _.indexOf(
- _.pluck( this.displaySteps, 'name' ),
- stepName
- );
-
- if ( stepIndex >= 0 ) {
- this.currentStepIndex = stepIndex;
- this.render();
- }
- },
-
+ this.progressView.goToStep( stepName );
+ this.render();
+ }
});
})(jQuery, _, Backbone, gettext);
diff --git a/lms/static/js/verify_student/views/payment_confirmation_step_view.js b/lms/static/js/verify_student/views/payment_confirmation_step_view.js
index 19a7e9a9c3..ec77fb2527 100644
--- a/lms/static/js/verify_student/views/payment_confirmation_step_view.js
+++ b/lms/static/js/verify_student/views/payment_confirmation_step_view.js
@@ -3,16 +3,133 @@
*/
var edx = edx || {};
-(function( $ ) {
+(function( $, _, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
- // The "Verify Later" button goes directly to the dashboard,
- // The "Verify Now" button reloads this page with the "skip-first-step"
- // flag set. This allows the user to navigate back to the confirmation
- // if he/she wants to.
- // For this reason, we don't need any custom click handlers here.
- edx.verify_student.PaymentConfirmationStepView = edx.verify_student.StepView.extend({});
+ edx.verify_student.PaymentConfirmationStepView = edx.verify_student.StepView.extend({
-})( jQuery );
+ /**
+ * Retrieve receipt information from the shopping cart.
+ *
+ * We make this request from JavaScript to encapsulate
+ * the verification Django app from the shopping cart app.
+ *
+ * This method checks the query string param
+ * ?payment-order-num, which can be set by the shopping cart
+ * before redirecting to the payment confirmation page.
+ * This step then reads the param and requests receipt information
+ * from the shopping cart. At no point does the "verify student"
+ * Django app interact directly with the shopping cart.
+ *
+ * @param {object} templateContext The original template context.
+ * @return {object} A JQuery promise that resolves with the modified
+ * template context.
+ */
+ updateContext: function( templateContext ) {
+ var view = this;
+ return $.Deferred(
+ function( defer ) {
+ var paymentOrderNum = $.url( '?payment-order-num' );
+ if ( paymentOrderNum ) {
+ // If there is a payment order number, try to retrieve
+ // the receipt information from the shopping cart.
+ view.getReceiptData( paymentOrderNum ).done(
+ function( data ) {
+ // Add the receipt info to the template context
+ _.extend( templateContext, { receipt: this.receiptContext( data ) } );
+ defer.resolveWith( view, [ templateContext ]);
+ }
+ ).fail(function() {
+ // Display an error
+ // This can occur if the user does not have access to the receipt
+ // or the order number is invalid.
+ defer.rejectWith(
+ this,
+ [
+ gettext( "Error" ),
+ gettext( "Could not retrieve payment information" )
+ ]
+ );
+ });
+ } else {
+ // If no payment order is provided, return the original context
+ // The template is responsible for displaying a default state.
+ _.extend( templateContext, { receipt: null } );
+ defer.resolveWith( view, [ templateContext ]);
+ }
+ }
+ ).promise();
+ },
+
+ /**
+ * The "Verify Later" button goes directly to the dashboard,
+ * The "Verify Now" button reloads this page with the "skip-first-step"
+ * flag set. This allows the user to navigate back to the confirmation
+ * if he/she wants to.
+ * For this reason, we don't need any custom click handlers here.
+ */
+ postRender: function() {},
+
+ /**
+ * Retrieve receipt data from the shoppingcart.
+ * @param {int} paymentOrderNum The order number of the payment.
+ * @return {object} JQuery Promise.
+ */
+ getReceiptData: function( paymentOrderNum ) {
+ return $.ajax({
+ url: _.sprintf( '/shoppingcart/receipt/%s/', paymentOrderNum ),
+ type: 'GET',
+ dataType: 'json',
+ context: this
+ });
+ },
+
+ /**
+ * Construct the template context from data received
+ * from the shopping cart receipt.
+ *
+ * @param {object} data Receipt data received from the server
+ * @return {object} Receipt template context.
+ */
+ receiptContext: function( data ) {
+ var view = this,
+ receiptContext;
+
+ receiptContext = {
+ orderNum: data.orderNum,
+ currency: data.currency,
+ purchasedDatetime: data.purchase_datetime,
+ totalCost: view.formatMoney( data.total_cost ),
+ isRefunded: data.status === "refunded",
+ billedTo: {
+ firstName: data.billed_to.first_name,
+ lastName: data.billed_to.last_name,
+ city: data.billed_to.city,
+ state: data.billed_to.state,
+ postalCode: data.billed_to.postal_code,
+ country: data.billed_to.country
+ },
+ items: []
+ };
+
+ receiptContext.items = _.map(
+ data.items,
+ function( item ) {
+ return {
+ lineDescription: item.line_desc,
+ cost: view.formatMoney( item.line_cost )
+ };
+ }
+ );
+
+ return receiptContext;
+ },
+
+ formatMoney: function( moneyStr ) {
+ return Number( moneyStr ).toFixed(2);
+ }
+ });
+
+})( jQuery, _, gettext );
diff --git a/lms/static/js/verify_student/views/progress_view.js b/lms/static/js/verify_student/views/progress_view.js
index c78659f880..8ebe6bf61a 100644
--- a/lms/static/js/verify_student/views/progress_view.js
+++ b/lms/static/js/verify_student/views/progress_view.js
@@ -18,6 +18,24 @@
this.currentStepIndex = obj.currentStepIndex || 0;
},
+ nextStep: function() {
+ this.currentStepIndex = Math.min(
+ this.currentStepIndex + 1,
+ this.displaySteps.length - 1
+ );
+ },
+
+ goToStep: function( stepName ) {
+ var stepIndex = _.indexOf(
+ _.pluck( this.displaySteps, 'name' ),
+ stepName
+ );
+
+ if ( stepIndex >= 0 ) {
+ this.currentStepIndex = stepIndex;
+ }
+ },
+
render: function() {
var renderedHtml, context;
diff --git a/lms/static/js/verify_student/views/step_view.js b/lms/static/js/verify_student/views/step_view.js
index 5371a10c01..869754566a 100644
--- a/lms/static/js/verify_student/views/step_view.js
+++ b/lms/static/js/verify_student/views/step_view.js
@@ -64,10 +64,10 @@
this.postRender();
},
- handleError: function() {
+ handleError: function( errorTitle, errorMsg ) {
this.errorModel.set({
- errorTitle: gettext( "Error" ),
- errorMsg: gettext( "An unexpected error occurred. Please reload the page to try again." ),
+ errorTitle: errorTitle || gettext( "Error" ),
+ errorMsg: errorMsg || gettext( "An unexpected error occurred. Please reload the page to try again." ),
shown: true
});
},
diff --git a/lms/templates/verify_student/enrollment_confirmation_step.underscore b/lms/templates/verify_student/enrollment_confirmation_step.underscore
index 4b2ff48c7c..1432ac9ea2 100644
--- a/lms/templates/verify_student/enrollment_confirmation_step.underscore
+++ b/lms/templates/verify_student/enrollment_confirmation_step.underscore
@@ -1 +1,50 @@
-
Enrollment confirmation!
+
+
+ <%- gettext( "Congratulations! You are now enrolled in the verified track." ) %>
+
+
<%- gettext( "You are now enrolled as a verified student! Your enrollment details are below.") %>
+
+
+
+ -
+
+ <%- gettext( "You are enrolled in " ) %> :
+
+
+
+ <%- gettext( "A list of courses you have just enrolled in as a verified student" ) %>
+
+
+ | <%- gettext( "Course" ) %> |
+ <%- gettext( "Status" ) %> |
+ <%- gettext( "Options" ) %> |
+
+
+
+
+
+ | <%- courseName %> |
+
+ <%- _.sprintf( gettext( "Starts: %(start)s" ), { start: courseStartDate } ) %>
+ |
+
+ <% if ( coursewareUrl ) { %>
+ <%- gettext( "Go to Course" ) %>
+ <% } %>
+ |
+
+
+
+
+ |
+ <%- gettext("Go to your dashboard") %>
+ |
+
+
+
+
+
+
+
+
+
diff --git a/lms/templates/verify_student/payment_confirmation_step.underscore b/lms/templates/verify_student/payment_confirmation_step.underscore
index 27f6698326..c0208c34ab 100644
--- a/lms/templates/verify_student/payment_confirmation_step.underscore
+++ b/lms/templates/verify_student/payment_confirmation_step.underscore
@@ -5,47 +5,80 @@
<%- gettext( "You are now enrolled as a verified student! Your enrollment details are below.") %>
+ <% if ( receipt ) { %>
- -
-
- <%- gettext( "You are enrolled in " ) %> :
-
+ -
+
<%- gettext( "Payment Details" ) %>
+
+
+
<%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %>
+
+
-
- <%- gettext( "A list of courses you have just enrolled in as a verified student" ) %>
+
- | <%- gettext( "Course" ) %> |
- <%- gettext( "Status" ) %> |
- <%- gettext( "Options" ) %> |
+ <%- gettext( "Order No." ) %> |
+ <%- gettext( "Description" ) %> |
+ <%- gettext( "Date" ) %> |
+ <%- gettext( "Description" ) %> |
-
- | <%- courseName %> |
-
- <%- _.sprintf( gettext( "Starts: %(start)s" ), { start: courseStartDate } ) %>
- |
-
- <% if ( coursewareUrl ) { %>
- <%- gettext( "Go to Course" ) %>
+ <% for ( var i = 0; i < receipt.items.length; i++ ) { %>
+ <% if ( receipt.isRefunded ) { %>
+ | <%- receipt.orderNum %> |
+ <%- receipt.items[i].lineDescription %> |
+ <%- receipt.purchasedDatetime %> |
+ <%- receipt.items[i].cost %> ($<%- receipt.currency.toUpperCase() %>) |
+ <% } else { %>
+
+ | <%- receipt.orderNum %> |
+ <%- receipt.items[i].lineDescription %> |
+ <%- receipt.purchasedDatetime %> |
+ <%- receipt.items[i].cost %> ($<%- receipt.currency.toUpperCase() %>) |
+
<% } %>
-
-
+ <% } %>
+
-
- |
- <%- gettext("Go to your dashboard") %>
+ |
+ | <%- gettext( "Total" ) %> |
+
+ <%- receipt.totalCost %>
+ (<%- receipt.currency.toUpperCase() %>)
|
+
+ <% if ( receipt.isRefunded ) { %>
+
+
<%- gettext( "Please Note" ) %>:
+
+
<%- gettext( "Items with strikethough 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." ) %>
+ <% } %>
<% if ( nextStepTitle ) { %>