From 0da4b13f25251c68c148dae6eb9210af4cd6deb2 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 6 Feb 2015 09:01:20 -0500 Subject: [PATCH] Add messaging for when students miss the verification deadline Show ordinary receipt unless the student is in the payment flow. --- lms/djangoapps/shoppingcart/models.py | 24 -- .../shoppingcart/tests/test_models.py | 7 +- .../shoppingcart/tests/test_views.py | 15 +- lms/djangoapps/shoppingcart/views.py | 69 +++-- .../verify_student/tests/test_views.py | 19 ++ lms/djangoapps/verify_student/views.py | 61 +++- .../shoppingcart/verified_cert_receipt.html | 263 ------------------ .../missed_verification_deadline.html | 18 ++ 8 files changed, 148 insertions(+), 328 deletions(-) delete mode 100644 lms/templates/shoppingcart/verified_cert_receipt.html create mode 100644 lms/templates/verify_student/missed_verification_deadline.html diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 32562455d1..d554b5e453 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1420,30 +1420,6 @@ class CertificateItem(OrderItem): self.course_enrollment.change_mode(self.mode) self.course_enrollment.activate() - @property - def single_item_receipt_template(self): - if self.mode in ('verified', 'professional'): - return 'shoppingcart/verified_cert_receipt.html' - else: - return super(CertificateItem, self).single_item_receipt_template - - @property - def single_item_receipt_context(self): - course = modulestore().get_course(self.course_id) - return { - "course_id": self.course_id, - "course_name": course.display_name_with_default, - "course_org": course.display_org_with_default, - "course_num": course.display_number_with_default, - "course_start_date_text": course.start_datetime_text(), - "course_has_started": course.start > datetime.today().replace(tzinfo=pytz.utc), - "course_root_url": reverse( - 'course_root', - kwargs={'course_id': self.course_id.to_deprecated_string()} # pylint: disable=no-member - ), - "dashboard_url": reverse('dashboard'), - } - def additional_instruction_text(self): refund_reminder = _( "You have up to two weeks into the course to unenroll from the Verified Certificate option " diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 63d0703b76..cda6aa0094 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -609,13 +609,10 @@ class CertificateItemTest(ModuleStoreTestCase): def test_single_item_template(self): cart = Order.get_cart_for_user(user=self.user) cert_item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - - self.assertEquals(cert_item.single_item_receipt_template, - 'shoppingcart/verified_cert_receipt.html') + self.assertEquals(cert_item.single_item_receipt_template, 'shoppingcart/receipt.html') cert_item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - self.assertEquals(cert_item.single_item_receipt_template, - 'shoppingcart/receipt.html') + self.assertEquals(cert_item.single_item_receipt_template, 'shoppingcart/receipt.html') @override_settings( SEGMENT_IO_LMS_KEY="foobar", diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index a729f18e05..bdbca88cbf 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -1281,7 +1281,7 @@ class ReceiptRedirectTest(ModuleStoreTestCase): password=self.PASSWORD ) - def test_show_receipt_redirect_to_verify_student(self): + def test_postpay_callback_redirect_to_verify_student(self): # Create other carts first # This ensures that the order ID and order item IDs do not match Order.get_cart_for_user(self.user).start_purchase() @@ -1296,11 +1296,13 @@ class ReceiptRedirectTest(ModuleStoreTestCase): self.COST, 'verified' ) - self.cart.purchase() + self.cart.start_purchase() - # Visit the receipt page - url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) - resp = self.client.get(url) + # Simulate hitting the post-pay callback + with patch('shoppingcart.views.process_postpay_callback') as mock_process: + mock_process.return_value = {'success': True, 'order': self.cart} + url = reverse('shoppingcart.views.postpay_callback') + resp = self.client.post(url, follow=True) # Expect to be redirected to the payment confirmation # page in the verify_student app @@ -1311,8 +1313,7 @@ class ReceiptRedirectTest(ModuleStoreTestCase): redirect_url += '?payment-order-num={order_num}'.format( order_num=self.cart.id ) - - self.assertRedirects(resp, redirect_url) + self.assertIn(redirect_url, resp.redirect_chain[0][0]) @override_settings(MODULESTORE=MODULESTORE_CONFIG) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index c4173510ae..a6580ba035 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -37,8 +37,9 @@ from .exceptions import ( from .models import ( Order, OrderTypes, PaidCourseRegistration, OrderItem, Coupon, - CouponRedemption, CourseRegistrationCode, RegistrationCodeRedemption, - CourseRegCodeItem, Donation, DonationConfiguration + CertificateItem, CouponRedemption, CourseRegistrationCode, + RegistrationCodeRedemption, CourseRegCodeItem, + Donation, DonationConfiguration ) from .processors import ( process_postpay_callback, render_purchase_form_html, @@ -595,6 +596,43 @@ def donate(request): return HttpResponse(response_params, content_type="text/json") +def _get_verify_flow_redirect(order): + """Check if we're in the verification flow and redirect if necessary. + + Arguments: + order (Order): The order received by the post-pay callback. + + Returns: + HttpResponseRedirect or None + + """ + # See if the order contained any certificate items + # If so, the user is coming from the payment/verification flow. + cert_items = CertificateItem.objects.filter(order=order) + + if cert_items.count() > 0: + # Currently, we allow the purchase of only one verified + # enrollment at a time; if there are more than one, + # this will choose the first. + if cert_items.count() > 1: + log.warning( + u"More than one certificate item in order %s; " + u"continuing with the payment/verification flow for " + u"the first order item (course %s).", + order.id, cert_items[0].course_id + ) + + course_id = cert_items[0].course_id + url = reverse( + 'verify_student_payment_confirmation', + kwargs={'course_id': unicode(course_id)} + ) + # Add a query string param for the order ID + # This allows the view to query for the receipt information later. + url += '?payment-order-num={order_num}'.format(order_num=order.id) + return HttpResponseRedirect(url) + + @csrf_exempt @require_POST def postpay_callback(request): @@ -609,7 +647,16 @@ def postpay_callback(request): """ params = request.POST.dict() result = process_postpay_callback(params) + if result['success']: + # See if this payment occurred as part of the verification flow process + # If so, send the user back into the flow so they have the option + # to continue with verification. + verify_flow_redirect = _get_verify_flow_redirect(result['order']) + if verify_flow_redirect is not None: + return verify_flow_redirect + + # Otherwise, send the user to the receipt page return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: return render_to_response('shoppingcart/error.html', {'order': result['order'], @@ -852,29 +899,13 @@ def _show_receipt_html(request, order): 'reg_code_info_list': reg_code_info_list, 'order_purchase_date': order.purchase_time.strftime("%B %d, %Y"), } + # We want to have the ability to override the default receipt page when # there is only one item in the order. if order_items.count() == 1: receipt_template = order_items[0].single_item_receipt_template context.update(order_items[0].single_item_receipt_context) - # Ideally, the shoppingcart app would own the receipt view. However, - # as a result of changes made to the payment and verification flows as - # part of an A/B test, the verify_student app owns it instead. This is - # left over, and will be made more general in the future. - if receipt_template == 'shoppingcart/verified_cert_receipt.html': - url = reverse( - 'verify_student_payment_confirmation', - kwargs={'course_id': unicode(order_items[0].course_id)} - ) - - # Add a query string param for the order ID - # This allows the view to query for the receipt information later. - url += '?payment-order-num={order_num}'.format( - order_num=order_items[0].order.id - ) - return HttpResponseRedirect(url) - return render_to_response(receipt_template, context) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 2967175ef1..077cbf983b 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -609,6 +609,25 @@ class TestPayAndVerifyView(ModuleStoreTestCase): data = self._get_page_data(response) self.assertEqual(data['verification_deadline'], "Jan 02, 2999 at 00:00 UTC") + def test_course_mode_expired(self): + course = self._create_course("verified") + mode = CourseMode.objects.get( + course_id=course.id, + mode_slug="verified" + ) + expiration = datetime(1999, 1, 2, tzinfo=pytz.UTC) + mode.expiration_datetime = expiration + mode.save() + + # Need to be enrolled + self._enroll(course.id, "verified") + + # The course mode has expired, so expect an explanation + # to the student that the deadline has passed + response = self._get_page("verify_student_verify_later", course.id) + self.assertContains(response, "verification deadline") + self.assertContains(response, "Jan 02, 1999 at 00:00 UTC") + def _create_course(self, *course_modes, **kwargs): """Create a new course with the specified course modes. """ course = CourseFactory.create() diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 2ac8704551..5f928c87d6 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -256,20 +256,36 @@ class PayAndVerifyView(View): log.warn(u"No course specified for verification flow request.") raise Http404 - # Verify that the course has a verified mode - course_mode = CourseMode.verified_mode_for_course(course_key) - if course_mode is None: + # Check that the course has an unexpired verified mode + course_mode, expired_course_mode = self._get_verified_modes_for_course(course_key) + + if course_mode is not None: + log.info( + u"Entering verified workflow for user '%s', course '%s', with current step '%s'.", + request.user.id, course_id, current_step + ) + elif expired_course_mode is not None: + # Check if there is an *expired* verified course mode; + # if so, we should show a message explaining that the verification + # deadline has passed. + log.info(u"Verification deadline for '%s' has passed.", course_id) + context = { + 'course': course, + 'deadline': ( + get_default_time_display(expired_course_mode.expiration_datetime) + if expired_course_mode.expiration_datetime else "" + ) + } + return render_to_response("verify_student/missed_verification_deadline.html", context) + else: + # Otherwise, there has never been a verified mode, + # so return a page not found response. log.warn( - u"No verified course mode found for course '{course_id}' for verification flow request" - .format(course_id=course_id) + u"No verified course mode found for course '%s' for verification flow request", + course_id ) raise Http404 - log.info( - u"Entering verified workflow for user '{user}', course '{course_id}', with current step '{current_step}'." - .format(user=request.user, course_id=course_id, current_step=current_step) - ) - # Check whether the user has verified, paid, and enrolled. # A user is considered "paid" if he or she has an enrollment # with a paid course mode (such as "verified"). @@ -427,6 +443,31 @@ class PayAndVerifyView(View): if url is not None: return redirect(url) + def _get_verified_modes_for_course(self, course_key): + """Retrieve unexpired and expired verified modes for a course. + + Arguments: + course_key (CourseKey): The location of the course. + + Returns: + Tuple of `(verified_mode, expired_verified_mode)`. If provided, + `verified_mode` is an *unexpired* verified mode for the course. + If provided, `expired_verified_mode` is an *expired* verified + mode for the course. Either of these may be None. + + """ + # Retrieve all the modes at once to reduce the number of database queries + all_modes, unexpired_modes = CourseMode.all_and_unexpired_modes_for_courses([course_key]) + + # Find an unexpired verified mode + verified_mode = CourseMode.verified_mode_for_course(course_key, modes=unexpired_modes[course_key]) + expired_verified_mode = None + + if verified_mode is None: + expired_verified_mode = CourseMode.verified_mode_for_course(course_key, modes=all_modes[course_key]) + + return (verified_mode, expired_verified_mode) + def _display_steps(self, always_show_payment, already_verified, already_paid): """Determine which steps to display to the user. diff --git a/lms/templates/shoppingcart/verified_cert_receipt.html b/lms/templates/shoppingcart/verified_cert_receipt.html deleted file mode 100644 index be1068d49c..0000000000 --- a/lms/templates/shoppingcart/verified_cert_receipt.html +++ /dev/null @@ -1,263 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -<%inherit file="../main.html" /> -<%block name="bodyclass">register verification-process step-confirmation - -<%block name="pagetitle">${_("Receipt (Order")} ${order.id}) - -<%block name="content"> -% if notification is not UNDEFINED: -
- ${notification} -
-% endif - -
-
- - - -
-
-

${_("Your Progress")}

- -
    -
  1. - 0 - ${_("Current Step: ")}${_("Intro")} -
  2. - -
  3. - 1 - ${_("Take Photo")} -
  4. - -
  5. - 2 - ${_("Take ID Photo")} -
  6. - -
  7. - 3 - ${_("Review")} -
  8. - -
  9. - 4 - ${_("Make Payment")} -
  10. - -
  11. - - - - ${_("Current Step: ")}${_("Confirmation")} -
  12. -
- - - - -
-
- -
-
-

${_("Congratulations! You are now verified on ")} ${_(settings.PLATFORM_NAME)}.

- -
-

${_("You are now registered as a verified student! Your registration details are below.")}

-
- -
    -
  • -

    ${_("You are registered for:")}

    - -
    - - - - - - - - - - - - % for item, course in shoppingcart_items: - - - - - - % endfor - - - - - - -
    ${_("A list of courses you have just registered for as a verified student")}
    ${_("Course")}${_("Status")}${_("Options")}
    ${item.line_desc} - ${_("Starts: {start_date}").format(start_date=course_start_date_text)} - - %if course_has_started: - ${_("Go to Course")} - %else: - %endif -
    - ${_("Go to your Dashboard")} -
    -
    -
  • - -
  • -

    ${_("Verified Status")}

    - -
    -

    ${_("We have received your identification details to verify your identity. If there is a problem with any of the items, we will contact you to resubmit. You can now register for any of the verified certificate courses this semester without having to re-verify.")}

    - -

    ${_("The professor will ask you to periodically submit a new photo to verify your work during the course (usually at exam times).")}

    -
    -
  • - -
  • -

    ${_("Payment Details")}

    - -
    -

    ${_("Please print this page for your records; it serves as your receipt. You will also receive an email with the same information.")}

    -
    - -
    - - - - - - - - - - - - % for item, course in shoppingcart_items: - - % if item.status == "purchased": - - - - - - % elif item.status == "refunded": - - - - - % endif - - % endfor - - - - - - - - -
    ${_("Order No.")}${_("Description")}${_("Date")}${_("Description")}
    ${order.id}${item.line_desc}${order.purchase_time.date().isoformat()}${"{0:0.2f}".format(item.line_cost)} (${item.currency.upper()})${order.id}${item.line_desc}${order.purchase_time.date().isoformat()}${"{0:0.2f}".format(item.line_cost)} (${item.currency.upper()})
    ${_("Total")} - ${"{0:0.2f}".format(order.total_cost)} - (${item.currency.upper()}) -
    - - % if any_refunds: -
    -

    Please Note:

    -
    - ## Translators: Please keep the "" and "" tags around your translation of the word "this" in your translation. -

    ${_("Note: items with strikethough like this have been refunded.")}

    -
    -
    - % endif -
    - -
    -

    ${_("Billed To")}: - ${order.bill_to_first} ${order.bill_to_last} (${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode} ${order.bill_to_country.upper()}) -

    -
    -
  • - - <%doc> -
  • -

    ${_("Billing Information")}

    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ${_("Billed To")}${_("Billing Address")}${_("Payment Method Type")}${_("Payment Method Details")}
    - ${order.bill_to_first} ${order.bill_to_last} - - ${order.bill_to_street1} - ${order.bill_to_street2} - - ${order.bill_to_street2}, - ${order.bill_to_state} - ${order.bill_to_postalcode} - - ${order.bill_to_country.upper()} - - ${order.bill_to_cardtype} - - ${order.bill_to_ccnum} -
    ${_("Total")}${"{0:0.2f}".format(order.total_cost)} (${item.currency.upper()})
    -
    -
  • - -
-
-
- -
-
- diff --git a/lms/templates/verify_student/missed_verification_deadline.html b/lms/templates/verify_student/missed_verification_deadline.html new file mode 100644 index 0000000000..5c69a3052d --- /dev/null +++ b/lms/templates/verify_student/missed_verification_deadline.html @@ -0,0 +1,18 @@ +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Verification Deadline Has Passed")} + +<%block name="content"> +
+

${_( + u"The verification deadline for {course_name} was {date}. " + u"Verification is no longer available." + ).format( + course_name=course.display_name, + date=deadline + )}

+
+