diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 8e475d1ec1c13c1593e60f5b7f15efed87ad71f3..b6849cd07bdf0e6dd4556decfc1a50eab703b15f 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -2,7 +2,7 @@ Add and create new modes for running courses on this particular LMS """ import pytz -from datetime import datetime +from datetime import datetime, date from django.db import models from collections import namedtuple @@ -101,6 +101,17 @@ class CourseMode(models.Model): modes = cls.modes_for_course(course_id) return min(mode.min_price for mode in modes if mode.currency == currency) + @classmethod + def refund_expiration_date(cls, course_id, mode_slug): + """ + Returns the expiration date for verified certificate refunds. After this date, refunds are + no longer possible. Note that this is currently set to be identical to the expiration date for + verified cert signups, but this could be changed in the future + """ + print "TODO fix this" + return date(1990, 1, 1) + #return cls.mode_for_course(course_id,mode_slug).expiration_date + def __unicode__(self): return u"{} : {}, min={}, prices={}".format( self.course_id, self.mode_slug, self.min_price, self.suggested_prices diff --git a/common/djangoapps/course_modes/tests/factories.py b/common/djangoapps/course_modes/tests/factories.py index 3e35b2f05c4b211daf9fe544371b413960af54c0..72f30bf3832a8bc0713990871fa3f8618fc4aca4 100644 --- a/common/djangoapps/course_modes/tests/factories.py +++ b/common/djangoapps/course_modes/tests/factories.py @@ -1,5 +1,6 @@ from course_modes.models import CourseMode from factory import DjangoModelFactory +import datetime # Factories don't have __init__ methods, and are self documenting # pylint: disable=W0232 @@ -11,3 +12,4 @@ class CourseModeFactory(DjangoModelFactory): mode_display_name = 'audit course' min_price = 0 currency = 'usd' + expiration_date = datetime.date(1990, 1, 1) diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 7a01c30dc4990a3d0d4ced8c7c7508046aec0eae..36b1e72bdd00398bb2287a86aaf020ec3caabc47 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -5,7 +5,7 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ -from datetime import datetime, timedelta +from datetime import datetime, date, timedelta import pytz from django.test import TestCase @@ -20,6 +20,7 @@ class CourseModeModelTest(TestCase): def setUp(self): self.course_id = 'TestCourse' CourseMode.objects.all().delete() + #todo use different default date def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices='', currency='usd'): """ @@ -31,7 +32,7 @@ class CourseModeModelTest(TestCase): mode_slug=mode_slug, min_price=min_price, suggested_prices=suggested_prices, - currency=currency + currency=currency, ) def test_modes_for_course_empty(self): @@ -112,3 +113,9 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course('second_test_course') self.assertEqual([CourseMode.DEFAULT_MODE], modes) + + def test_refund_expiration_date(self): + self.create_mode('verified', 'Verified Certificate') + modes = CourseMode.modes_for_course(self.course_id) + mode = Mode(u'verified', u'Verified Certificate', 0, '', 'usd') + self.assertEqual(CourseMode.refund_expiration_date(self.course_id, 'verified'), date(1990, 1, 1)) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 315b6e928510cbe6007049cdaff1cff475c03ef2..ed8237870ffc1ac62e36c8dd3b8e162860a973e1 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -422,3 +422,28 @@ class PaidRegistrationTest(ModuleStoreTestCase): self.assertEqual(response.content, reverse('shoppingcart.views.show_cart')) self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order( shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id)) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class CertificateItemTest(ModuleStoreTestCase): + """ + Tests for paid certificate functionality (verified student), involves shoppingcart + """ + # test data + COURSE_SLUG = "100" + COURSE_NAME = "test_course" + COURSE_ORG = "EDX" + + def setUp(self): + # Create course + self.req_factory = RequestFactory() + self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG) + self.assertIsNotNone(self.course) + self.user = User.objects.create(username="test", email="test@test.org") + + def test_unenroll_and_refund(self): + request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id, 'enrollment_action': 'unenroll'}) + request.user = self.user + response = change_enrollment(request) + self.assertEqual(response.status_code, 200) + # add more later; see if this even works diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 75c9b758213f9fa8e63aa664f196ec88776a4d78..6b30927c46293d408074ed9137058b0a6bac3b02 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -2,6 +2,7 @@ Student Views """ import datetime +from datetime import date import json import logging import random @@ -65,6 +66,7 @@ import external_auth.views from bulk_email.models import Optout, CourseAuthorization import shoppingcart +from shoppingcart.models import (Order, OrderItem, CertificateItem) import track.views @@ -300,6 +302,7 @@ def dashboard(request): # exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. courses = [] + refund_status = [] for enrollment in CourseEnrollment.enrollments_for_user(user): try: courses.append((course_from_id(enrollment.course_id), enrollment)) @@ -335,15 +338,19 @@ def dashboard(request): CourseAuthorization.instructor_email_enabled(course.id) ) ) + # Verification Attempts verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) + + show_refund_option_for = frozenset(course.id for course, _enrollment in courses + if (has_access(request.user, course, 'refund') and (_enrollment.mode == "verified"))) + # get info w.r.t ExternalAuthMap external_auth_map = None try: external_auth_map = ExternalAuthMap.objects.get(user=user) except ExternalAuthMap.DoesNotExist: pass - context = {'courses': courses, 'course_optouts': course_optouts, 'message': message, @@ -356,6 +363,7 @@ def dashboard(request): 'show_email_settings_for': show_email_settings_for, 'verification_status': verification_status, 'verification_msg': verification_msg, + 'show_refund_option_for': show_refund_option_for, } return render_to_response('dashboard.html', context) @@ -424,6 +432,8 @@ def change_enrollment(request): .format(user.username, course_id)) return HttpResponseBadRequest(_("Course id is invalid")) + course = course_from_id(course_id) + if not has_access(user, course, 'enroll'): return HttpResponseBadRequest(_("Enrollment is closed")) @@ -464,19 +474,42 @@ def change_enrollment(request): elif action == "unenroll": try: - CourseEnrollment.unenroll(user, course_id) - - org, course_num, run = course_id.split("/") - dog_stats_api.increment( - "common.student.unenrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)] - ) + course = course_from_id(course_id) + except ItemNotFoundError: + log.warning("User {0} tried to unenroll from non-existent course {1}" + .format(user.username, course_id)) + return HttpResponseBadRequest(_("Course id is invalid")) + + course = course_from_id(course_id) + verified = CourseEnrollment.enrollment_mode_for_user(user, course_id) + # did they sign up for verified certs? + if(verified): + + # If the user is allowed a refund, do so + if has_access(user, course, 'refund'): + subject = _("[Refund] User-Requested Refund") + # todo: make this reference templates/student/refund_email.html + message = "Important info here." + to_email = [settings.PAYMENT_SUPPORT_EMAIL] + from_email = "support@edx.org" + try: + send_mail(subject, message, from_email, to_email, fail_silently=False) + except: + log.warning('Unable to send reimbursement request to billing', exc_info=True) + js['value'] = _('Could not send reimbursement request.') + return HttpResponse(json.dumps(js)) + # email has been sent, let's deal with the order now + CertificateItem.refund_cert(user, course_id) + CourseEnrollment.unenroll(user, course_id) - return HttpResponse() - except CourseEnrollment.DoesNotExist: - return HttpResponseBadRequest(_("You are not enrolled in this course")) + org, course_num, run = course_id.split("/") + dog_stats_api.increment( + "common.student.unenrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)] + ) + return HttpResponse() else: return HttpResponseBadRequest(_("Enrollment action is invalid")) @@ -891,7 +924,7 @@ def create_account(request, post_override=None): subject = ''.join(subject.splitlines()) message = render_to_string('emails/activation_email.txt', d) - # dont send email if we are doing load testing or random user generation for some reason + # don't send email if we are doing load testing or random user generation for some reason if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING')): try: if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'): diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 7836ab8bbce5d6f159b2c374b5ba8ded5f5c0fc7..e8b527766d0a5e4fe69fc2c8300c402d1244e010 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -2,7 +2,7 @@ Ideally, it will be the only place that needs to know about any special settings like DISABLE_START_DATES""" import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date from functools import partial from django.conf import settings @@ -202,11 +202,33 @@ def _has_access_course_desc(user, course, action): return can_enroll() or can_load() + def can_refund(): + """ + For paid/verified certificates, students may receive a refund IFF the deadline + for refunds has not yet passed. Note that this function *only* checks whether + or not that deadline has passed; checking whether the student actually *purchased* + a paid/verified certificate must be done elsewhere. + """ + now = datetime.now(UTC()) + course_start = course.enrollment_start + # If there *is* no start date, user can be refunded + if course_start is None: + return True + # Presently, refunds are only allowed up to two weeks after the course + # start date. + grace_period = timedelta(days=14) + refund_end = course_start + grace_period + if (now.date() <= refund_end.date()): + return True + return False + + checkers = { 'load': can_load, 'load_forum': can_load_forum, 'enroll': can_enroll, 'see_exists': see_exists, + 'refund': can_refund, 'staff': lambda: _has_staff_access_to_descriptor(user, course), 'instructor': lambda: _has_instructor_access_to_descriptor(user, course), } diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index a1cd12ae241306d185dff4ee05419c09a3a29f48..22dd7e25fb61f7e8843bc09f580f8f7ee1dbf8f2 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -106,3 +106,20 @@ class AccessTestCase(TestCase): # TODO: # Non-staff cannot enroll outside the open enrollment period if not specifically allowed + + def test__has_access_refund(self): + u = Mock() + today = datetime.datetime.now(UTC()) + grace_period = datetime.timedelta(days=14) + one_day_extra = datetime.timedelta(days=1) + + # User is allowed to receive refund if it is within two weeks of course start date + c = Mock(enrollment_start=(today-one_day_extra), id='edX/tests/Whenever') + self.assertTrue(access._has_access_course_desc(u, c, 'refund')) + + c = Mock(enrollment_start=(today-grace_period), id='edX/test/Whenever') + self.assertTrue(access._has_access_course_desc(u, c, 'refund')) + + # After two weeks, user may no longer receive a refund + c = Mock(enrollment_start=(today-grace_period-one_day_extra), id='edX/test/Whenever') + self.assertFalse(access._has_access_course_desc(u, c, 'refund')) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 9ae11f6554aada58bdb26815e61e9ff713f88aa7..761290d557438298d5b15d2667f94fe22d5cfeca 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -9,7 +9,7 @@ from boto.exception import BotoServerError # this is a super-class of SESError from django.db import models from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import (ObjectDoesNotExist, MultipleObjectsReturned) from django.core.mail import send_mail from django.contrib.auth.models import User from django.utils.translation import ugettext as _ @@ -22,7 +22,6 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from course_modes.models import CourseMode from mitxmako.shortcuts import render_to_string -from student.views import course_from_id from student.models import CourseEnrollment from verify_student.models import SoftwareSecurePhotoVerification @@ -34,13 +33,19 @@ log = logging.getLogger("shoppingcart") ORDER_STATUSES = ( ('cart', 'cart'), ('purchased', 'purchased'), - ('refunded', 'refunded'), # Not used for now + ('refunded', 'refunded'), ) # we need a tuple to represent the primary key of various OrderItem subclasses OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk']) # pylint: disable=C0103 +def course_from_id(course_id): + """Return the CourseDescriptor corresponding to this course_id""" + course_loc = CourseDescriptor.id_to_location(course_id) + return modulestore().get_instance(course_id, course_loc) + + class Order(models.Model): """ This is the model for an order. Before purchase, an Order and its related OrderItems are used @@ -398,6 +403,23 @@ class CertificateItem(OrderItem): course_enrollment = models.ForeignKey(CourseEnrollment) mode = models.SlugField() + @classmethod + def refund_cert(cls, target_user, target_course_id): + try: + target_cert = CertificateItem.objects.get(course_id=target_course_id, user_id=target_user, status='purchased', mode='verified') + target_cert.status = 'refunded' + # todo return success + return target_cert + except MultipleObjectsReturned: + # this seems like a thing that shouldn't happen + log.exception("Multiple entries for single verified cert found") + # but we can recover; select one item and refund it + # todo + except ObjectDoesNotExist: + # todo log properly + log.exception("No certificate found") + # handle the exception + @classmethod @transaction.commit_on_success def add_to_order(cls, order, course_id, cost, mode, currency='usd'): diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 1224bb5093b33b072c0eff7bb62dccfe27be9ff8..14d136853aa450ef3ac80f881e3753ef3286541d 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -20,6 +20,7 @@ from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode from shoppingcart.exceptions import PurchasedCallbackException +from django.core.exceptions import (ObjectDoesNotExist, MultipleObjectsReturned) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -360,3 +361,27 @@ class CertificateItemTest(ModuleStoreTestCase): cert_item = CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') self.assertEquals(cert_item.single_item_receipt_template, 'shoppingcart/receipt.html') + + def test_refund_cert_single_cert(self): + # enroll and buy; dup from test_existing_enrollment + CourseEnrollment.enroll(self.user, self.course_id) + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + cart.purchase() + enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) + # now that it's there, let's try refunding it + order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id) + self.assertEquals(order.status, 'refunded') + + def test_refund_cert_no_cert_exists(self): + order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id) + self.assertRaises(ObjectDoesNotExist) + + def test_refund_cert_duplicate_certs_exist(self): + for i in range(0, 2): + CourseEnrollment.enroll(self.user, self.course_id) + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + cart.purchase() + enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) + self.assertRaises(MultipleObjectsReturned) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index e1eadf1331dec2d15443872ebf361c8e9cc15ca6..94362e055fdb74eb27f7e81668ab0fa26849e8df 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -20,6 +20,7 @@ USE_I18N = True TEMPLATE_DEBUG = True +MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True MITX_FEATURES['DISABLE_START_DATES'] = False MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up @@ -269,7 +270,7 @@ if SEGMENT_IO_LMS_KEY: CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = os.environ.get('CYBERSOURCE_SHARED_SECRET', '') CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = os.environ.get('CYBERSOURCE_MERCHANT_ID', '') CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '') -CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '') +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' ########################## USER API ######################## diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index eb657d13e410c764ba20f145cd4aee2fe4627612..3a59ed1ba426d8446ef65ef49b8b162bdfbe1503 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -190,7 +190,8 @@ <% cert_status = cert_statuses.get(course.id) %> <% show_email_settings = (course.id in show_email_settings_for) %> <% course_mode_info = all_course_modes.get(course.id) %> - <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info" /> + <% show_refund_option = (course.id in show_refund_option_for) %> + <%include file='dashboard/dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option" /> % endfor </ul> @@ -244,26 +245,7 @@ </div> </section> -<section id="unenroll-modal" class="modal unenroll-modal" aria-hidden="true"> - <div class="inner-wrapper" role="alertdialog" aria-labelledy="unenrollment-modal-title"> - <button class="close-modal">✕ <span class="sr">${_('Close Modal')}</span></button> - - <header> - <h2 id="unenrollment-modal-title">${_('Are you sure you want to unregister from {course_number}?').format(course_number='<span id="unenroll_course_number"></span>')}<span class="sr">, ${_("modal open")}</span></h2> - <hr/> - </header> - - <div id="unenroll_error" class="modal-form-error"></div> - <form id="unenroll_form" method="post" data-remote="true" action="${reverse('change_enrollment')}"> - <input name="course_id" id="unenroll_course_id" type="hidden" /> - <input name="enrollment_action" type="hidden" value="unenroll" /> - <div class="submit"> - <input name="submit" type="submit" value="${_('Unregister')}" /> - </div> - </form> - </div> -</section> <section id="password_reset_complete" class="modal" aria-hidden="true"> <div class="inner-wrapper" role="dialog" aria-labelledby="password-reset-email"> diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index c43e7e79de02c640441342c43414cbc104a970d4..40dae64cbacf4e887febbc489808650c01f280e4 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info" /> +<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option" /> <%! from django.utils.translation import ugettext as _ %> <%! @@ -143,10 +143,41 @@ <a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}">${_('Unregister')}</a> + + % if show_email_settings: <a href="#email-settings-modal" class="email-settings" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" data-optout="${course.id in course_optouts}">${_('Email Settings')}</a> % endif + + </section> </article> </li> + +<section id="unenroll-modal" class="modal unenroll-modal" aria-hidden="true"> + <div class="inner-wrapper" role="alertdialog" aria-labelledy="unenrollment-modal-title"> + <button class="close-modal">✕ <span class="sr">${_('Close Modal')}</span></button> + + <header> + % if enrollment.mode != "verified": + <h2 id="unenrollment-modal-title">${_('Are you sure you want to unregister from {course_number}?').format(course_number='<span id="unenroll_course_number"></span>')}<span class="sr">, ${_("modal open")}</span></h2> + % elif show_refund_option: + <h2 id="unenrollment-modal-title">${_('Are you sure you want to unregister from {course_number}? You will be refunded for the amount paid for the verified certificate.').format(course_number='<span id="unenroll_course_number"></span>')}<span class="sr">, ${_("modal open")}</span></h2> + % else: + <h2 id="unenrollment-modal-title">${_('Are you sure you want to unregister from {course_number}? The deadline for verified certificate refunds has passed, so you will not receive any money back.').format(course_number='<span id="unenroll_course_number"></span>')}<span class="sr">, ${_("modal open")}</span></h2> + % endif + <hr/> + </header> + + <div id="unenroll_error" class="modal-form-error"></div> + + <form id="unenroll_form" method="post" data-remote="true" action="${reverse('change_enrollment')}"> + <input name="course_id" id="unenroll_course_id" type="hidden" /> + <input name="enrollment_action" type="hidden" value="unenroll" /> + <div class="submit"> + <input name="submit" type="submit" value="${_('Unregister')}" /> + </div> + </form> + </div> +</section>