Refactored to use signals; full test coverage
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ########################
|
||||
|
||||
@@ -141,9 +141,13 @@
|
||||
% endif
|
||||
% endif
|
||||
|
||||
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}">${_('Unregister')}</a>
|
||||
|
||||
|
||||
% if enrollment.mode != "verified":
|
||||
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" onclick="document.getElementById('refund-info').innerHTML=''">${_('Unregister')}</a>
|
||||
% elif show_refund_option:
|
||||
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" onclick="document.getElementById('refund-info').innerHTML='You will be refunded for the amount you paid'">${_('Unregister')}</a>
|
||||
% else:
|
||||
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" onclick="document.getElementById('refund-info').innerHTML='The refund deadline for this course has passed, so you will not receive money back'">${_('Unregister')}</a>
|
||||
% endif
|
||||
|
||||
% 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>
|
||||
@@ -158,20 +162,12 @@
|
||||
<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
|
||||
<h2 id="unenrollment-modal-title">${_('Are you sure you want to unregister from {course_number}? <span id="refund-info"></span>').format(course_number='<span id="unenroll_course_number"></span>')}<span class="sr">, ${_("modal open")}</span></h2>
|
||||
refund
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user