From 829f7dba5f0a9a3ee76d810519084c813794905a Mon Sep 17 00:00:00 2001 From: Muhammad Shoaib Date: Thu, 27 Nov 2014 17:59:58 +0500 Subject: [PATCH] Remove items from the shopping cart if they are for a course whose course enrollment window has closed changed the url name to verify_cart and change the color of the message in the shopping cart --- common/djangoapps/student/models.py | 15 ++- lms/djangoapps/shoppingcart/models.py | 9 ++ .../shoppingcart/tests/test_views.py | 115 ++++++++++++++++ lms/djangoapps/shoppingcart/urls.py | 1 + lms/djangoapps/shoppingcart/views.py | 83 ++++++++++-- lms/static/js/shoppingcart/shoppingcart.js | 126 ++++++++++++++++++ lms/static/js/spec/main.js | 5 + .../js/spec/shoppingcart/shoppingcart_spec.js | 59 ++++++++ lms/static/sass/views/_shoppingcart.scss | 17 +++ .../shoppingcart/billing_details.html | 15 ++- lms/templates/shoppingcart/shopping_cart.html | 13 +- .../shoppingcart/shopping_cart_flow.html | 5 +- 12 files changed, 436 insertions(+), 27 deletions(-) create mode 100644 lms/static/js/shoppingcart/shoppingcart.js create mode 100644 lms/static/js/spec/shoppingcart/shoppingcart_spec.js diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 2a373afb68..407701cace 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -769,6 +769,17 @@ class CourseEnrollment(models.Model): return enrollment_number + @classmethod + def is_enrollment_closed(cls, user, course): + """ + Returns a boolean value regarding whether the user has access to enroll in the course. Returns False if the + enrollment has been closed. + """ + # Disable the pylint error here, as per ormsbee. This local import was previously + # in CourseEnrollment.enroll + from courseware.access import has_access # pylint: disable=import-error + return not has_access(user, 'enroll', course) + @classmethod def is_course_full(cls, course): """ @@ -904,8 +915,6 @@ class CourseEnrollment(models.Model): Also emits relevant events for analytics purposes. """ - from courseware.access import has_access - # All the server-side checks for whether a user is allowed to enroll. try: course = modulestore().get_course(course_key) @@ -921,7 +930,7 @@ class CourseEnrollment(models.Model): if check_access: if course is None: raise NonExistentCourseError - if not has_access(user, 'enroll', course): + if CourseEnrollment.is_enrollment_closed(user, course): log.warning( "User {0} failed to enroll in course {1} because enrollment is closed".format( user.username, diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 43737e3def..79f5b5931e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -152,6 +152,15 @@ class Order(models.Model): return False + @classmethod + def remove_cart_item_from_order(cls, item): + """ + Removes the item from the cart if the item.order.status == 'cart'. + """ + if item.order.status == 'cart': + log.info("Item {0} removed from the user cart".format(item.id)) + item.delete() + @property def total_cost(self): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 8f48572baa..236322268e 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -1,6 +1,7 @@ """ Tests for Shopping Cart views """ +import pytz from urlparse import urlparse from django.http import HttpRequest @@ -1102,6 +1103,120 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self._assert_404(reverse('shoppingcart.views.register_courses', args=[])) +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) +class ShoppingcartViewsClosedEnrollment(ModuleStoreTestCase): + """ + Test suite for ShoppingcartViews Course Enrollments Closed or not + """ + def setUp(self): + + super(ShoppingcartViewsClosedEnrollment, self).setUp() + self.user = UserFactory.create() + self.user.set_password('password') + self.user.save() + self.instructor = AdminFactory.create() + self.cost = 40 + + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_key = self.course.id + self.course_mode = CourseMode(course_id=self.course_key, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.testing_course = CourseFactory.create( + org='Edx', + number='999', + display_name='Testing Super Course', + metadata={"invitation_only": False} + ) + self.course_mode = CourseMode(course_id=self.testing_course.id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + self.now = datetime.now(pytz.UTC) + self.tomorrow = self.now + timedelta(days=1) + self.nextday = self.tomorrow + timedelta(days=1) + + def login_user(self): + """ + Helper fn to login self.user + """ + self.client.login(username=self.user.username, password="password") + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_to_check_that_cart_item_enrollment_is_closed(self): + self.login_user() + reg_item1 = PaidCourseRegistration.add_to_order(self.cart, self.course_key) + PaidCourseRegistration.add_to_order(self.cart, self.testing_course.id) + + # update the testing_course enrollment dates + self.testing_course.enrollment_start = self.tomorrow + self.testing_course.enrollment_end = self.nextday + self.testing_course = self.update_course(self.testing_course, self.user.id) + + # testing_course enrollment is closed but the course is in the cart + # so we delete that item from the cart and display the message in the cart + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertIn("{course_name} has been removed because the enrollment period has closed.".format(course_name=self.testing_course.display_name), resp.content) + + ((template, context), _tmp) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/shopping_cart.html') + self.assertEqual(context['order'], self.cart) + self.assertIn(reg_item1, context['shoppingcart_items'][0]) + self.assertEqual(1, len(context['shoppingcart_items'])) + self.assertEqual(True, context['is_course_enrollment_closed']) + self.assertIn(self.testing_course.display_name, context['appended_expired_course_names']) + + def test_to_check_that_cart_item_enrollment_is_closed_when_clicking_the_payment_button(self): + self.login_user() + PaidCourseRegistration.add_to_order(self.cart, self.course_key) + PaidCourseRegistration.add_to_order(self.cart, self.testing_course.id) + + # update the testing_course enrollment dates + self.testing_course.enrollment_start = self.tomorrow + self.testing_course.enrollment_end = self.nextday + self.testing_course = self.update_course(self.testing_course, self.user.id) + + # testing_course enrollment is closed but the course is in the cart + # so we delete that item from the cart and display the message in the cart + resp = self.client.get(reverse('shoppingcart.views.verify_cart')) + self.assertEqual(resp.status_code, 200) + self.assertTrue(json.loads(resp.content)['is_course_enrollment_closed']) + + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertIn("{course_name} has been removed because the enrollment period has closed.".format(course_name=self.testing_course.display_name), resp.content) + self.assertIn('40.00', resp.content) + + def test_is_enrollment_closed_when_order_type_is_business(self): + self.login_user() + self.cart.order_type = 'business' + self.cart.save() + PaidCourseRegistration.add_to_order(self.cart, self.course_key) + CourseRegCodeItem.add_to_order(self.cart, self.testing_course.id, 2) + + # update the testing_course enrollment dates + self.testing_course.enrollment_start = self.tomorrow + self.testing_course.enrollment_end = self.nextday + self.testing_course = self.update_course(self.testing_course, self.user.id) + + resp = self.client.post(reverse('shoppingcart.views.billing_details')) + self.assertEqual(resp.status_code, 200) + self.assertTrue(json.loads(resp.content)['is_course_enrollment_closed']) + + # testing_course enrollment is closed but the course is in the cart + # so we delete that item from the cart and display the message in the cart + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertIn("{course_name} has been removed because the enrollment period has closed.".format(course_name=self.testing_course.display_name), resp.content) + self.assertIn('40.00', resp.content) + + @override_settings(MODULESTORE=MODULESTORE_CONFIG) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) class RegistrationCodeRedemptionCourseEnrollment(ModuleStoreTestCase): diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 2ae1f408a4..36ec4df114 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -16,6 +16,7 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^update_user_cart/$', 'update_user_cart'), url(r'^reset_code_redemption/$', 'reset_code_redemption'), url(r'^billing_details/$', 'billing_details', name='billing_details'), + url(r'^verify_cart/$', 'verify_cart'), url(r'^register_courses/$', 'register_courses'), ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 799651910c..c3288384ef 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -148,25 +148,27 @@ def show_cart(request): This view shows cart items. """ cart = Order.get_cart_for_user(request.user) - total_cost = cart.total_cost - cart_items = cart.orderitem_set.all().select_subclasses() - shoppingcart_items = [] - for cart_item in cart_items: - course_key = getattr(cart_item, 'course_id') - if course_key: - course = get_course_by_id(course_key, depth=0) - shoppingcart_items.append((cart_item, course)) - + is_any_course_expired, expired_cart_items, expired_cart_item_names, valid_cart_item_tuples = \ + verify_for_closed_enrollment(request.user, cart) site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME) + if is_any_course_expired: + for expired_item in expired_cart_items: + Order.remove_cart_item_from_order(expired_item) + cart.update_order_type() + + appended_expired_course_names = ", ".join(expired_cart_item_names) + callback_url = request.build_absolute_uri( reverse("shoppingcart.views.postpay_callback") ) form_html = render_purchase_form_html(cart, callback_url=callback_url) context = { 'order': cart, - 'shoppingcart_items': shoppingcart_items, - 'amount': total_cost, + 'shoppingcart_items': valid_cart_item_tuples, + 'amount': cart.total_cost, + 'is_course_enrollment_closed': is_any_course_expired, + 'appended_expired_course_names': appended_expired_course_names, 'site_name': site_name, 'form_html': form_html, 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1], @@ -559,8 +561,7 @@ def billing_details(request): """ cart = Order.get_cart_for_user(request.user) - cart_items = cart.orderitem_set.all() - + cart_items = cart.orderitem_set.all().select_subclasses() if getattr(cart, 'order_type') != OrderTypes.BUSINESS: raise Http404('Page not found!') @@ -589,11 +590,65 @@ def billing_details(request): cart.add_billing_details(company_name, company_contact_name, company_contact_email, recipient_name, recipient_email, customer_reference_number) + + is_any_course_expired, __, __, __ = verify_for_closed_enrollment(request.user) + return JsonResponse({ - 'response': _('success') + 'response': _('success'), + 'is_course_enrollment_closed': is_any_course_expired }) # status code 200: OK by default +def verify_for_closed_enrollment(user, cart=None): + """ + A multi-output helper function. + inputs: + user: a user object + cart: If a cart is provided it uses the same object, otherwise fetches the user's cart. + Returns: + is_any_course_expired: True if any of the items in the cart has it's enrollment period closed. False otherwise. + expired_cart_items: List of courses with enrollment period closed. + expired_cart_item_names: List of names of the courses with enrollment period closed. + valid_cart_item_tuples: List of courses which are still open for enrollment. + """ + if cart is None: + cart = Order.get_cart_for_user(user) + expired_cart_items = [] + expired_cart_item_names = [] + valid_cart_item_tuples = [] + cart_items = cart.orderitem_set.all().select_subclasses() + is_any_course_expired = False + for cart_item in cart_items: + course_key = getattr(cart_item, 'course_id', None) + if course_key is not None: + course = get_course_by_id(course_key, depth=0) + if CourseEnrollment.is_enrollment_closed(user, course): + is_any_course_expired = True + expired_cart_items.append(cart_item) + expired_cart_item_names.append(course.display_name) + else: + valid_cart_item_tuples.append((cart_item, course)) + + return is_any_course_expired, expired_cart_items, expired_cart_item_names, valid_cart_item_tuples + + +@require_http_methods(["GET"]) +@login_required +@enforce_shopping_cart_enabled +def verify_cart(request): + """ + Called when the user clicks the button to transfer control to CyberSource. + Returns a JSON response with is_course_enrollment_closed set to True if any of the courses has its + enrollment period closed. If all courses are still valid, is_course_enrollment_closed set to False. + """ + is_any_course_expired, __, __, __ = verify_for_closed_enrollment(request.user) + return JsonResponse( + { + 'is_course_enrollment_closed': is_any_course_expired + } + ) # status code 200: OK by default + + @login_required def show_receipt(request, ordernum): """ diff --git a/lms/static/js/shoppingcart/shoppingcart.js b/lms/static/js/shoppingcart/shoppingcart.js new file mode 100644 index 0000000000..75c6d2a795 --- /dev/null +++ b/lms/static/js/shoppingcart/shoppingcart.js @@ -0,0 +1,126 @@ +var edx = edx || {}; + +(function($) { + 'use strict'; + + edx.shoppingcart = edx.shoppingcart || {}; + edx.shoppingcart.showcart = {}; + + /** + * View for making shoppingcart + * @constructor + * @param {Object} params + * @param {Object} params.el - The payment form element. + */ + edx.shoppingcart.showcart.CartView = function(params) { + /** + * cart view that checks that all the cart items are valid (course enrollment is closed or not) + * before the form submitted to the payment processor. + * @param {Object} form - The form to modify. + */ + + /** + * Check for all the cart items are still valid (courses enrollment are not closed) + * + * @returns {Object} The promise from the AJAX call to the server, + * which checks for cart items are valid or not and returns the boolean + * { is_course_enrollment_closed: } + */ + var isCourseEnrollmentAllowed = function() { + return $.ajax({ + url: "/shoppingcart/verify_cart/", + type: "GET" + }); + }; + + var view = { + /** + * Initialize the view. + * + * @param {Object} params + * @param {JQuery selector} params.el - The payment form element. + * @returns {CartView} + */ + initialize: function(params) { + this.$el = params.el; + _.bindAll(view, + 'submit', 'responseFromServer', + 'submitPaymentForm', 'errorFromServer' + ); + return this; + }, + + /** + * Handle a click event on the "payment form submit" button. + * This will contact the LMS server to check for all the + * valid cart items (courses enrollment should not be closed at this point) + * then send the user to the external payment processor or redirects to the + * dashboard page + * + * @param {Object} event - The click event. + */ + submit: function(event) { + // Prevent form submission + if (event) { + event.preventDefault(); + } + + // Immediately disable the submit button to prevent duplicate submissions + this.$el.find('input[type="submit"]').addClass("disabled"); + + this.$paymentForm = this.$el; + isCourseEnrollmentAllowed() + .done(this.responseFromServer) + .fail(this.errorFromServer); + return this; + }, + + /** + * Send signed payment parameters to the external + * payment processor if cart items are valid else redirect to + * shoppingcart. + * + * @param {boolean} data.is_course_enrollment_closed + */ + responseFromServer: function(data) { + if (data.is_course_enrollment_closed == true) { + location.href = "/shoppingcart"; + } + else { + this.submitPaymentForm(this.$paymentForm); + } + }, + + /** + * In case the server responded back with errors + * + */ + errorFromServer: function() { + // Immediately enable the submit button to allow submission + this.$el.find('input[type="submit"]').removeClass("disabled"); + }, + + /** + * Submit the payment from to the external payment processor. + * + * @param {Object} form + */ + submitPaymentForm: function(form) { + form.submit(); + } + }; + + view.initialize(params); + return view; + }; + + $(document).ready(function() { + // (click on the payment submit button). + $('.cart-view form input[type="submit"]').click(function(event) { + var container = $('.confirm-enrollment.cart-view form'); + var view = new edx.shoppingcart.showcart.CartView({ + el:container + }).submit(event); + }); + }); +})(jQuery); \ No newline at end of file diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index dad276d970..d177124b9c 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -261,6 +261,10 @@ exports: 'js/dashboard/donation', deps: ['jquery', 'underscore', 'gettext'] }, + 'js/shoppingcart/shoppingcart.js': { + exports: 'js/shoppingcart/shoppingcart', + deps: ['jquery', 'underscore', 'gettext'] + }, // Backbone classes loaded explicitly until they are converted to use RequireJS 'js/models/cohort': { @@ -382,6 +386,7 @@ 'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/views/notification_spec.js', 'lms/include/js/spec/dashboard/donation.js', + 'lms/include/js/spec/shoppingcart/shoppingcart_spec.js', 'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/login_spec.js', diff --git a/lms/static/js/spec/shoppingcart/shoppingcart_spec.js b/lms/static/js/spec/shoppingcart/shoppingcart_spec.js new file mode 100644 index 0000000000..0c465f790c --- /dev/null +++ b/lms/static/js/spec/shoppingcart/shoppingcart_spec.js @@ -0,0 +1,59 @@ +define(['js/common_helpers/ajax_helpers', 'js/shoppingcart/shoppingcart'], + function(AjaxHelpers) { + 'use strict'; + + describe("edx.shoppingcart.showcart.CartView", function() { + var view = null; + var requests = null; + + beforeEach(function() { + setFixtures('
'); + + view = new edx.shoppingcart.showcart.CartView({ + el: $('.confirm-enrollment.cart-view form') + }); + + spyOn(view, 'responseFromServer').andCallFake(function() {}); + + // Spy on AJAX requests + requests = AjaxHelpers.requests(this); + + view.submit(); + + // Verify that the client contacts the server to + // check for all th valid cart items + AjaxHelpers.expectRequest( + requests, "GET", "/shoppingcart/verify_cart/" + ); + }); + + it("cart has invalid items, course enrollment has been closed", function() { + // Simulate a response from the server containing the + // parameter 'is_course_enrollment_closed'. This decides that + // do we have all the cart items valid in the cart or not + AjaxHelpers.respondWithJson(requests, { + is_course_enrollment_closed: true + }); + + expect(view.responseFromServer).toHaveBeenCalled(); + var data = view.responseFromServer.mostRecentCall.args[0] + expect(data.is_course_enrollment_closed).toBe(true); + + }); + + it("cart has all valid items, course enrollment is still open", function() { + // Simulate a response from the server containing the + // parameter 'is_course_enrollment_closed'. This decides that + // do we have all the cart items valid in the cart or not + AjaxHelpers.respondWithJson(requests, { + is_course_enrollment_closed: false + }); + + expect(view.responseFromServer).toHaveBeenCalled(); + var data = view.responseFromServer.mostRecentCall.args[0] + expect(data.is_course_enrollment_closed).toBe(false); + + }); + }); + } +); \ No newline at end of file diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index 0514244897..231532cd33 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -171,6 +171,15 @@ } } } +#expiry-msg { + padding: 15px; + background-color: #f2f2f2; + margin-top: 3px; + font-family: $sans-serif; + font-size: 14px; + text-shadow: 0px 1px 1px #fff; + border-top: 1px solid #f0f0f0; +} .confirm-enrollment { .title { font-size:24px; @@ -885,6 +894,14 @@ text-align: center; margin-top: 20px; text-transform: initial; + margin-bottom: 5px; + } + p { + font-size: 14px; + font-family: $sans-serif; + color: #9d9d9d; + text-align: center; + text-shadow: 0px 1px 1px #fff; } a.blue{ display: inline-block; diff --git a/lms/templates/shoppingcart/billing_details.html b/lms/templates/shoppingcart/billing_details.html index 3ea256d5af..af5cd63646 100644 --- a/lms/templates/shoppingcart/billing_details.html +++ b/lms/templates/shoppingcart/billing_details.html @@ -8,7 +8,7 @@ <%block name="custom_content">
% if shoppingcart_items: -
+

${_('You can proceed to payment at any point in time. Any additional information you provide will be included in your receipt.')}

@@ -93,6 +93,8 @@ return false; } event.preventDefault(); + // Disable the submit button to prevent duplicate submissions + $(this).addClass("disabled"); var post_url = "${reverse('billing_details')}"; var data = { "company_name" : $('input[name="company_name"]').val(), @@ -104,10 +106,15 @@ }; $.post(post_url, data) .success(function(data) { - payment_form.submit(); - }) + if (data.is_course_enrollment_closed == true) { + location.href = "${reverse('shoppingcart.views.show_cart')}"; + } + else { + payment_form.submit(); + } + }) .error(function(data,status) { - + $(this).removeClass("disabled"); }) }); }); diff --git a/lms/templates/shoppingcart/shopping_cart.html b/lms/templates/shoppingcart/shopping_cart.html index 88a26c55f4..a9c4f5a42e 100644 --- a/lms/templates/shoppingcart/shopping_cart.html +++ b/lms/templates/shoppingcart/shopping_cart.html @@ -21,13 +21,15 @@ from django.utils.translation import ugettext as _ % endif + % if is_course_enrollment_closed: +

${_('{course_names} has been removed because the enrollment period has closed.').format(course_names=appended_expired_course_names)}

+ % endif: <% discount_applied = False order_type = 'personal' %> - -
+
% for item, course in shoppingcart_items: % if loop.index > 0 :
@@ -135,7 +137,10 @@ from django.utils.translation import ugettext as _ % else:

${_('Your Shopping cart is currently empty.')}

- ${_('View Courses')} + % if is_course_enrollment_closed: +

${_('{course_names} has been removed because the enrollment period has closed.').format(course_names=appended_expired_course_names)}

+ % endif + ${_('View Courses')}
% endif @@ -146,7 +151,7 @@ from django.utils.translation import ugettext as _ var isSpinnerBtnEnabled = true; var prevQty = 0; - $('a.btn-remove').click(function(event) { + $('a.btn-remove').click(function(event) { event.preventDefault(); var post_url = "${reverse('shoppingcart.views.remove_item')}"; $.post(post_url, {id:$(this).data('item-id')}) diff --git a/lms/templates/shoppingcart/shopping_cart_flow.html b/lms/templates/shoppingcart/shopping_cart_flow.html index 298a8e6f11..365a3d5a2b 100644 --- a/lms/templates/shoppingcart/shopping_cart_flow.html +++ b/lms/templates/shoppingcart/shopping_cart_flow.html @@ -4,10 +4,11 @@ from django.utils.translation import ugettext as _ <%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="pagetitle">${_("Shopping cart")} - <%! from django.conf import settings %> <%! from microsite_configuration import microsite %> - +<%block name="headextra"> + + <%block name="bodyextra">