From 2ea2b8f6747c65641c966c92b700fe4f14432568 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 15 Dec 2014 14:30:45 -0500 Subject: [PATCH] Add JSON end-point for shoppingcart receipt information Add enrollment confirmation page Fix progress update Add order info to the payment confirmation page. --- .../shoppingcart/tests/test_views.py | 99 +++++++++++++ lms/djangoapps/shoppingcart/views.py | 66 ++++++++- lms/envs/common.py | 1 + .../js/verify_student/pay_and_verify.js | 5 + .../views/pay_and_verify_view.js | 35 ++--- .../views/payment_confirmation_step_view.js | 133 ++++++++++++++++-- .../js/verify_student/views/progress_view.js | 18 +++ .../js/verify_student/views/step_view.js | 6 +- .../enrollment_confirmation_step.underscore | 51 ++++++- .../payment_confirmation_step.underscore | 77 +++++++--- 10 files changed, 433 insertions(+), 58 deletions(-) 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 ) { %> + <% } else { %> +

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

+ <% } %> <% if ( nextStepTitle ) { %>