diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 8e475d1ec1..ec2cbf1245 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -11,7 +11,6 @@ from django.db.models import Q Mode = namedtuple('Mode', ['slug', 'name', 'min_price', 'suggested_prices', 'currency', 'expiration_date']) - class CourseMode(models.Model): """ We would like to offer a course in a variety of modes. @@ -72,8 +71,8 @@ class CourseMode(models.Model): @classmethod def modes_for_course_dict(cls, course_id): """ - Returns the modes for a particular course as a dictionary with - the mode slug as the key + Returns the non-expired modes for a particular course as a + dictionary with the mode slug as the key """ return {mode.slug: mode for mode in cls.modes_for_course(course_id)} @@ -82,6 +81,8 @@ class CourseMode(models.Model): """ Returns the mode for the course corresponding to mode_slug. + Returns only non-expired modes. + If this particular mode is not set for the course, returns None """ modes = cls.modes_for_course(course_id) @@ -95,7 +96,8 @@ class CourseMode(models.Model): @classmethod def min_course_price_for_currency(cls, course_id, currency): """ - Returns the minimum price of the course in the appropriate currency over all the course's modes. + Returns the minimum price of the course in the appropriate currency over all the course's + non-expired modes. If there is no mode found, will return the price of DEFAULT_MODE, which is 0 """ modes = cls.modes_for_course(course_id) diff --git a/common/djangoapps/course_modes/tests/factories.py b/common/djangoapps/course_modes/tests/factories.py index 3e35b2f05c..0d519b3ad5 100644 --- a/common/djangoapps/course_modes/tests/factories.py +++ b/common/djangoapps/course_modes/tests/factories.py @@ -11,3 +11,4 @@ class CourseModeFactory(DjangoModelFactory): mode_display_name = 'audit course' min_price = 0 currency = 'usd' + expiration_date = None diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 7a01c30dc4..7f09fbf7cc 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -31,7 +31,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): diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 78e673df71..794c6ef7b9 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -17,18 +17,20 @@ import json import logging import uuid - from django.conf import settings from django.contrib.auth.models import User from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver +import django.dispatch from django.forms import ModelForm, forms +from course_modes.models import CourseMode import comment_client as cc from pytz import UTC +unenroll_done = django.dispatch.Signal(providing_args=["course_enrollment"]) log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") @@ -825,6 +827,7 @@ class CourseEnrollment(models.Model): record = CourseEnrollment.objects.get(user=user, course_id=course_id) record.is_active = False record.save() + unenroll_done.send(sender=cls, course_enrollment=record) except cls.DoesNotExist: err_msg = u"Tried to unenroll student {} from {} but they were not enrolled" log.error(err_msg.format(user, course_id)) @@ -924,6 +927,18 @@ class CourseEnrollment(models.Model): self.is_active = False self.save() + def refundable(self): + """ + For paid/verified certificates, students may receive a refund IFF they have + a verified certificate and the deadline for refunds has not yet passed. + """ + course_mode = CourseMode.mode_for_course(self.course_id, 'verified') + if course_mode is None: + return False + else: + return True + + class CourseEnrollmentAllowed(models.Model): """ diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 315b6e9285..41a95ff13a 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -256,6 +256,22 @@ class DashboardTest(TestCase): self.assertFalse(course_mode_info['show_upsell']) self.assertIsNone(course_mode_info['days_for_upsell']) + def test_refundable(self): + verified_mode = CourseModeFactory.create( + course_id=self.course.id, + mode_slug='verified', + mode_display_name='Verified', + expiration_date=(datetime.now(pytz.UTC) + timedelta(days=1)).date() + ) + enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='verified') + + self.assertTrue(enrollment.refundable()) + + verified_mode.expiration_date = (datetime.now(pytz.UTC) - timedelta(days=1)).date() + verified_mode.save() + self.assertFalse(enrollment.refundable()) + + class EnrollInCourseTest(TestCase): """Tests enrolling and unenrolling in courses.""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 75c9b75821..b6904f2fb7 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -47,7 +47,6 @@ from student.models import ( from student.forms import PasswordResetFormNoActive from verify_student.models import SoftwareSecurePhotoVerification - from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -73,6 +72,7 @@ from pytz import UTC from util.json_request import JsonResponse + log = logging.getLogger("mitx.student") AUDIT_LOG = logging.getLogger("audit") @@ -296,13 +296,13 @@ def complete_course_mode_info(course_id, enrollment): def dashboard(request): user = request.user - # Build our courses list for the user, but ignore any courses that no longer - # exist (because the course IDs have changed). Still, we don't delete those + # Build our (course, enorllment) list for the user, but ignore any courses that no + # longer exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. - courses = [] + course_enrollment_pairs = [] for enrollment in CourseEnrollment.enrollments_for_user(user): try: - courses.append((course_from_id(enrollment.course_id), enrollment)) + course_enrollment_pairs.append((course_from_id(enrollment.course_id), enrollment)) except ItemNotFoundError: log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) @@ -321,22 +321,27 @@ def dashboard(request): staff_access = True errored_courses = modulestore().get_errored_courses() - show_courseware_links_for = frozenset(course.id for course, _enrollment in courses + show_courseware_links_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs if has_access(request.user, course, 'load')) - course_modes = {course.id: complete_course_mode_info(course.id, enrollment) for course, enrollment in courses} - cert_statuses = {course.id: cert_info(request.user, course) for course, _enrollment in courses} + course_modes = {course.id: complete_course_mode_info(course.id, enrollment) for course, enrollment in course_enrollment_pairs} + cert_statuses = {course.id: cert_info(request.user, course) for course, _enrollment in course_enrollment_pairs} # only show email settings for Mongo course and when bulk email is turned on show_email_settings_for = frozenset( - course.id for course, _enrollment in courses if ( + course.id for course, _enrollment in course_enrollment_pairs if ( settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE and 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 course_enrollment_pairs + if _enrollment.refundable()) + # get info w.r.t ExternalAuthMap external_auth_map = None try: @@ -344,7 +349,7 @@ def dashboard(request): except ExternalAuthMap.DoesNotExist: pass - context = {'courses': courses, + context = {'course_enrollment_pairs': course_enrollment_pairs, 'course_optouts': course_optouts, 'message': message, 'external_auth_map': external_auth_map, @@ -356,6 +361,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) @@ -463,20 +469,17 @@ 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)] - ) - - return HttpResponse() - except CourseEnrollment.DoesNotExist: + if not CourseEnrollment.is_enrolled(user, course_id): return HttpResponseBadRequest(_("You are not enrolled in this course")) + 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)] + ) + return HttpResponse() else: return HttpResponseBadRequest(_("Enrollment action is invalid")) @@ -891,7 +894,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'): @@ -1512,4 +1515,4 @@ def change_email_settings(request): log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') - return HttpResponse(json.dumps({'success': True})) + return HttpResponse(json.dumps({'success': True})) \ No newline at end of file diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index a1cd12ae24..bad5f95e0c 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -1,15 +1,21 @@ from mock import Mock from django.test import TestCase +from django.test.utils import override_settings from xmodule.modulestore import Location import courseware.access as access +from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from .factories import CourseEnrollmentAllowedFactory import datetime -from django.utils.timezone import UTC +import pytz +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class AccessTestCase(TestCase): + """ + Tests for the various access controls on the student dashboard + """ def test__has_global_staff_access(self): u = Mock(is_staff=False) self.assertFalse(access._has_global_staff_access(u)) @@ -71,7 +77,7 @@ class AccessTestCase(TestCase): # TODO: override DISABLE_START_DATES and test the start date branch of the method u = Mock() d = Mock() - d.start = datetime.datetime.now(UTC()) - datetime.timedelta(days=1) # make sure the start time is in the past + d.start = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1) # make sure the start time is in the past # Always returns true because DISABLE_START_DATES is set in test.py self.assertTrue(access._has_access_descriptor(u, d, 'load')) @@ -79,8 +85,8 @@ class AccessTestCase(TestCase): def test__has_access_course_desc_can_enroll(self): u = Mock() - yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1) - tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1) + yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1) + tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1) c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow, enrollment_domain='') # User can enroll if it is between the start and end dates diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 9ae11f6554..03be80861a 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -7,6 +7,7 @@ from model_utils.managers import InheritanceManager from collections import namedtuple from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors +from django.dispatch import receiver from django.db import models from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -23,7 +24,8 @@ 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 student.models import CourseEnrollment, unenroll_done + from verify_student.models import SoftwareSecurePhotoVerification from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, @@ -34,7 +36,7 @@ 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 @@ -398,6 +400,49 @@ class CertificateItem(OrderItem): course_enrollment = models.ForeignKey(CourseEnrollment) mode = models.SlugField() + @receiver(unenroll_done, sender=CourseEnrollment) + def refund_cert_callback(sender, course_enrollment=None, **kwargs): + """ + When a CourseEnrollment object calls its unenroll method, this function checks to see if that unenrollment + occurred in a verified certificate that was within the refund deadline. If so, it actually performs the + refund. + + Returns the refunded certificate on a successful refund; else, it returns nothing. + """ + + # Only refund verified cert unenrollments that are within bounds of the expiration date + if not course_enrollment.refundable(): + return + + target_certs = CertificateItem.objects.filter(course_id=course_enrollment.course_id, user_id=course_enrollment.user, status='purchased', mode='verified') + try: + target_cert = target_certs[0] + except IndexError: + log.error("Matching CertificateItem not found while trying to refund. User %s, Course %s", course_enrollment.user, course_enrollment.course_id) + return + target_cert.status = 'refunded' + target_cert.save() + + order_number = target_cert.order_id + # send billing an email so they can handle refunding + subject = _("[Refund] User-Requested Refund") + message = "User {user} ({user_email}) has requested a refund on Order #{order_number}.".format(user=course_enrollment.user, + user_email=course_enrollment.user.email, + order_number=order_number) + to_email = [settings.PAYMENT_SUPPORT_EMAIL] + from_email = [settings.PAYMENT_SUPPORT_EMAIL] + try: + send_mail(subject, message, from_email, to_email, fail_silently=False) + except (smtplib.SMTPException, BotoServerError): + err_str = 'Failed sending email to billing request a refund for verified certiciate (User {user}, Course {course}, CourseEnrollmentID {ce_id}, Order #{order})' + log.error(err_str.format( + user=course_enrollment.user, + course=course_enrollment.course_id, + ce_id=course_enrollment.id, + order=order_number)) + + return target_cert + @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 1224bb5093..f158efe2b3 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -20,6 +20,8 @@ from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode from shoppingcart.exceptions import PurchasedCallbackException +import pytz +import datetime @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -360,3 +362,89 @@ 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_callback_no_expiration(self): + # When there is no expiration date on a verified mode, the user can always get a refund + CourseEnrollment.enroll(self.user, self.course_id, 'verified') + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + cart.purchase() + + CourseEnrollment.unenroll(self.user, self.course_id) + target_certs = CertificateItem.objects.filter(course_id=self.course_id, user_id=self.user, status='refunded', mode='verified') + self.assertTrue(target_certs[0]) + + def test_refund_cert_callback_before_expiration(self): + # If the expiration date has not yet passed on a verified mode, the user can be refunded + course_id = "refund_before_expiration/test/one" + many_days = datetime.timedelta(days=60) + + CourseFactory.create(org='refund_before_expiration', number='test', run='course', display_name='one') + course_mode = CourseMode(course_id=course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost, + expiration_date=(datetime.datetime.now(pytz.utc).date() + many_days)) + course_mode.save() + + CourseEnrollment.enroll(self.user, course_id, 'verified') + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, course_id, self.cost, 'verified') + cart.purchase() + + CourseEnrollment.unenroll(self.user, course_id) + target_certs = CertificateItem.objects.filter(course_id=course_id, user_id=self.user, status='refunded', mode='verified') + self.assertTrue(target_certs[0]) + + @patch('shoppingcart.models.log.error') + def test_refund_cert_callback_before_expiration_email_error(self, error_logger): + # If there's an error sending an email to billing, we need to log this error + course_id = "refund_before_expiration/test/one" + many_days = datetime.timedelta(days=60) + + CourseFactory.create(org='refund_before_expiration', number='test', run='course', display_name='one') + course_mode = CourseMode(course_id=course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost, + expiration_date=(datetime.datetime.now(pytz.utc).date() + many_days)) + course_mode.save() + + CourseEnrollment.enroll(self.user, course_id, 'verified') + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, course_id, self.cost, 'verified') + cart.purchase() + + with patch('shoppingcart.models.send_mail', side_effect=smtplib.SMTPException): + CourseEnrollment.unenroll(self.user, course_id) + self.assertTrue(error_logger.called) + + def test_refund_cert_callback_after_expiration(self): + # If the expiration date has passed, the user cannot get a refund + course_id = "refund_after_expiration/test/two" + many_days = datetime.timedelta(days=60) + + CourseFactory.create(org='refund_after_expiration', number='test', run='course', display_name='two') + course_mode = CourseMode(course_id=course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost,) + course_mode.save() + + CourseEnrollment.enroll(self.user, course_id, 'verified') + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, course_id, self.cost, 'verified') + cart.purchase() + + course_mode.expiration_date = (datetime.datetime.now(pytz.utc).date() - many_days) + course_mode.save() + + CourseEnrollment.unenroll(self.user, course_id) + target_certs = CertificateItem.objects.filter(course_id=course_id, user_id=self.user, status='refunded', mode='verified') + self.assertEqual(len(target_certs), 0) + + def test_refund_cert_no_cert_exists(self): + # If there is no paid certificate, the refund callback should return nothing + CourseEnrollment.enroll(self.user, self.course_id, 'verified') + ret_val = CourseEnrollment.unenroll(self.user, self.course_id) + self.assertFalse(ret_val) diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index eb657d13e4..9d9600475c 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -183,14 +183,15 @@

${_("Current Courses")}

- % if len(courses) > 0: + % if len(course_enrollment_pairs) > 0: @@ -244,26 +245,7 @@ - + + \ No newline at end of file diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index c43e7e79de..30c60fb9f1 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 _ %> <%! @@ -141,12 +141,20 @@ % endif % endif - ${_('Unregister')} + % if enrollment.mode != "verified": + ${_('Unregister')} + % elif show_refund_option: + ${_('Unregister')} + % else: + ${_('Unregister')} + % endif % if show_email_settings: ${_('Email Settings')} % endif + + - + \ No newline at end of file