diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 78e673df71..ae5e10dc75 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -17,7 +17,6 @@ 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 @@ -29,6 +28,9 @@ from django.forms import ModelForm, forms import comment_client as cc from pytz import UTC +import django.dispatch + +verified_unenroll_done = django.dispatch.Signal(providing_args=["user", "user_email", "course_id"]) log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 6c802977d0..3366296aa0 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -35,7 +35,8 @@ from student.tests.factories import UserFactory, CourseModeFactory from student.tests.test_email import mock_render_to_string import shoppingcart -from shoppingcart.models import CertificateItem + +from course_modes.models import CourseMode COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' @@ -426,9 +427,9 @@ class PaidRegistrationTest(ModuleStoreTestCase): @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) -class CertificateItemTest(ModuleStoreTestCase): +class RefundUnenrollmentTests(ModuleStoreTestCase): """ - Tests for paid certificate functionality (verified student), involves shoppingcart + Tests views for unenrollment with refunds """ # test data COURSE_SLUG = "100" @@ -437,19 +438,36 @@ class CertificateItemTest(ModuleStoreTestCase): def setUp(self): # Create course, user, and enroll them as a verified student + self.user = UserFactory.create() + self.course_id = "org/test/Test_Course" + self.cost = 40 + CourseFactory.create(org='org', number='test', run='course', display_name='Test Course') + course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + course_mode.save() + course_mode = CourseMode(course_id=self.course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost) + course_mode.save() + 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") - CourseEnrollment.enroll(self.user, self.course.id, mode='verified') + + course_enrollment = CourseEnrollment.create_enrollment(self.user, self.course_id, 'verified', is_active=True) + course_enrollment.save() # Student is verified and paid; we should be able to refund them def test_unenroll_and_refund(self): - request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id, 'enrollment_action': 'unenroll'}) + 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) - self.assertFalse(CourseEnrollment.is_enrolled(self.user,self.course.id)) - target_certs = CertificateItem.objects.filger(course_id=self.course.id, user_id=self.user, status='refunded') - self.assertTrue(target_certs[0].status == 'refunded') + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + def test_unenroll_but_no_course(self): + request = self.req_factory.post(reverse('change_enrollment'), {'course_id': 'non/existent/course', 'enrollment_action': 'unenroll'}) + request.user = self.user + response = change_enrollment(request) + self.assertEqual(response.status_code, 400) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 46ef470fb4..00480a7401 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -43,13 +43,12 @@ from student.models import ( TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, get_testcenter_registration, CourseEnrollmentAllowed, UserStanding, + verified_unenroll_done ) from student.forms import PasswordResetFormNoActive from verify_student.models import SoftwareSecurePhotoVerification - from certificates.models import CertificateStatuses, certificate_status_for_student - from shoppingcart.models import CertificateItem from xmodule.course_module import CourseDescriptor @@ -75,6 +74,7 @@ from pytz import UTC from util.json_request import JsonResponse + log = logging.getLogger("mitx.student") AUDIT_LOG = logging.getLogger("audit") @@ -476,22 +476,11 @@ def change_enrollment(request): enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, course_id) # did they sign up for verified certs? - if(enrollment_mode=='verified'): + if(enrollment_mode == '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) + # triggers the callback to mark the certificate as refunded + verified_unenroll_done.send(sender=request, user=user, user_email=user.email, course_id=course_id) CourseEnrollment.unenroll(user, course_id) org, course_num, run = course_id.split("/") dog_stats_api.increment( diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index bd4f6e9841..97e64eea91 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -108,18 +108,18 @@ class AccessTestCase(TestCase): # Non-staff cannot enroll outside the open enrollment period if not specifically allowed def test__has_access_refund(self): - u = Mock() + user = 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')) + course = Mock(enrollment_start=(today - one_day_extra), id='edX/tests/Whenever') + self.assertTrue(access._has_access_course_desc(user, course, 'refund')) - c = Mock(enrollment_start=(today - grace_period), id='edX/test/Whenever') - self.assertTrue(access._has_access_course_desc(u, c, 'refund')) + course = Mock(enrollment_start=(today - grace_period), id='edX/test/Whenever') + self.assertTrue(access._has_access_course_desc(user, course, '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')) + course = Mock(enrollment_start=(today - grace_period - one_day_extra), id='edX/test/Whenever') + self.assertFalse(access._has_access_course_desc(user, course, 'refund')) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 77d6d7898a..834034624e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -7,6 +7,9 @@ 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 student.models import verified_unenroll_done + from django.db import models from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -22,7 +25,9 @@ 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 from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, @@ -40,12 +45,6 @@ ORDER_STATUSES = ( 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 @@ -404,24 +403,38 @@ class CertificateItem(OrderItem): mode = models.SlugField() @classmethod - def refund_cert(cls, target_user, target_course_id): + @receiver(verified_unenroll_done) + def refund_cert_callback(sender, **kwargs): """ - When refunded, this should find a verified certificate purchase for target_user in target_course_id, change that - certificate's status to "refunded", save that result, and return the refunded certificate. - - Note the actual mechanics of refunding money occurs elsewhere; this simply changes the relevant certificate's - status for the refund. + When a CourseEnrollment object whose mode is 'verified' has its is_active field set to false (i.e. when a student + is unenrolled), this callback ensures that the associated CertificateItem is marked as refunded, and that an + appropriate email is sent to billing. """ try: + course_id = kwargs['course_id'] + user = kwargs['user'] + user_email = kwargs['user_email'] + # If there's duplicate entries, just grab the first one and refund it (though in most cases we should only get one) - target_certs = CertificateItem.objects.filter(course_id=target_course_id, user_id=target_user, status='purchased', mode='verified') + target_certs = CertificateItem.objects.filter(course_id=course_id, user_id=user, status='purchased', mode='verified') target_cert = target_certs[0] 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 " + str(user) + "(" + str(user_email) + ") has requested a refund on Order #" + str(order_number) + "." + to_email = [settings.PAYMENT_SUPPORT_EMAIL] + from_email = "support@edx.org" + send_mail(subject, message, from_email, to_email, fail_silently=False) + return target_cert - except IndexError or ObjectDoesNotExist: + + except IndexError: log.exception("No certificate found") - # handle the exception + raise IndexError @classmethod @transaction.commit_on_success diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index bf96878f13..6891cf140d 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -17,10 +17,9 @@ from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration, OrderItemSubclassPK) from student.tests.factories import UserFactory -from student.models import CourseEnrollment +from student.models import CourseEnrollment, verified_unenroll_done 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) @@ -362,24 +361,17 @@ class CertificateItemTest(ModuleStoreTestCase): self.assertEquals(cert_item.single_item_receipt_template, 'shoppingcart/receipt.html') - def test_refund_cert_single_cert(self): + def test_refund_cert_callback(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() # 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') + verified_unenroll_done.send(sender=self, user=self.user, user_email=self.user.email, course_id=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_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() - self.assertRaises(MultipleObjectsReturned) + with self.assertRaises(IndexError): + verified_unenroll_done.send(sender=self, user=self.user, user_email=self.user.email, course_id=self.course_id) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 94362e055f..e1eadf1331 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -20,7 +20,6 @@ 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 @@ -270,7 +269,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'] = '/shoppingcart/payment_fake/' +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '') ########################## USER API ######################## diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 40dae64cba..13346c9842 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -141,9 +141,13 @@ % endif % endif - ${_('Unregister')} - - + % if enrollment.mode != "verified": + ${_('Unregister')} + % elif show_refund_option: + ${_('Unregister')} + % else: + ${_('Unregister')} + % endif % if show_email_settings: ${_('Email Settings')} @@ -158,20 +162,12 @@