Response to Dave & Diana's CR

This commit is contained in:
Julia Hansbrough
2013-10-30 13:33:34 +00:00
parent bf916b6624
commit 7ab8142e82
11 changed files with 165 additions and 139 deletions

View File

@@ -14,6 +14,7 @@ from xmodule.modulestore import Location
from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed
from course_modes.models import CourseMode
from external_auth.models import ExternalAuthMap
from courseware.masquerade import is_masquerading_as_student
from django.utils.timezone import UTC
@@ -209,18 +210,11 @@ def _has_access_course_desc(user, course, action):
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:
course_mode = CourseMode.mode_for_course(course.id, 'verified')
if course_mode is None:
return False
else:
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 = {

View File

@@ -12,10 +12,14 @@ from django.utils.timezone import UTC
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.tests.factories import CourseModeFactory
@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))
@@ -114,19 +118,26 @@ class AccessTestCase(TestCase):
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed
def test__has_access_refund(self):
user = UserFactory.create()
course = CourseFactory.create(org='org', number='test', run='course', display_name='Test Course')
today = datetime.datetime.now(UTC())
grace_period = datetime.timedelta(days=14)
one_day_extra = datetime.timedelta(days=1)
user = UserFactory.create()
# User is allowed to receive refund if it is within two weeks of course start date
course.enrollment_start = (today - one_day_extra)
self.assertTrue(access._has_access_course_desc(user, course, 'refund'))
course_nonrefundable_id = 'nonrefundable/test/Test_Course'
course_nonrefundable = CourseFactory.create(org='nonrefundable', number='test', run='course', display_name='Test Course')
course_mode_nonrefundable = CourseModeFactory.create(course_id=course_nonrefundable_id,
mode_slug='verified',
expiration_date=(today - one_day_extra))
course_mode_nonrefundable.save()
course.enrollment_start = (today - grace_period)
self.assertTrue(access._has_access_course_desc(user, course, 'refund'))
course_refundable_id = 'refundable/test/Test_Course'
course_refundable = CourseFactory.create(org='refundable', number='test', run='course', display_name='Test Course')
course_mode_refundable = CourseModeFactory.create(course_id=course_refundable_id,
mode_slug='verified',
expiration_date=(today + one_day_extra))
course_mode_refundable.save()
# User cannot receive a refund one day after the expiration date
self.assertFalse(access._has_access_course_desc(user, course_nonrefundable, 'refund'))
# After two weeks, user may no longer receive a refund
course.enrollment_start = (today - grace_period - one_day_extra)
self.assertFalse(access._has_access_course_desc(user, course, 'refund'))
self.assertTrue(access._has_access_course_desc(user, course_refundable, 'refund'))

View File

@@ -8,8 +8,6 @@ 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
@@ -26,7 +24,7 @@ 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
@@ -402,38 +400,48 @@ class CertificateItem(OrderItem):
course_enrollment = models.ForeignKey(CourseEnrollment)
mode = models.SlugField()
@classmethod
@receiver(verified_unenroll_done)
@receiver(unenroll_done, sender=CourseEnrollment)
def refund_cert_callback(sender, **kwargs):
"""
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.
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. If no refund is necessary, it returns nothing.
If an error that the user should see occurs, it returns a string specifying the error.
"""
mode = kwargs['course_enrollment'].mode
course_id = kwargs['course_enrollment'].course_id
user = kwargs['course_enrollment'].user
# Only refund verified cert unenrollments that are within bounds of the expiration date
if (mode != 'verified'):
return
if (CourseMode.mode_for_course(course_id, 'verified') is None):
return
target_certs = CertificateItem.objects.filter(course_id=course_id, user_id=user, status='purchased', mode='verified')
try:
course_id = kwargs['course_id']
user = kwargs['user']
# 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=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:
log.exception("Matching CertificateItem not found while trying to refund. User %s, Course %s", user, course_id)
raise IndexError
log.error("Matching CertificateItem not found while trying to refund. User %s, Course %s", user, 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=user, user_email=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):
log.error('Failed sending email to billing request a refund for verified certiciate (User %s, Course %s)', user, course_id)
return target_cert
@classmethod
@transaction.commit_on_success

View File

@@ -17,9 +17,11 @@ 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, verified_unenroll_done
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException
from django.utils.timezone import UTC
import datetime
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@@ -361,17 +363,85 @@ class CertificateItemTest(ModuleStoreTestCase):
self.assertEquals(cert_item.single_item_receipt_template,
'shoppingcart/receipt.html')
def test_refund_cert_callback(self):
def test_refund_cert_callback_no_expiration(self):
# enroll and buy; dup from test_existing_enrollment
CourseEnrollment.enroll(self.user, self.course_id)
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()
# now that it's there, let's try refunding it
verified_unenroll_done.send(sender=self, user=self.user, user_email=self.user.email, course_id=self.course_id)
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):
# enroll and buy; dup from test_existing_enrollment
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(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()
# now that it's there, let's try refunding it
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):
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(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()
# now that it's there, let's try refunding it
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):
# Enroll and buy
course_id = "refund_after_expiration/test/two"
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.save()
# now that it's there, let's try refunding it
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])
def test_refund_cert_no_cert_exists(self):
with self.assertRaises(IndexError):
verified_unenroll_done.send(sender=self, user=self.user, user_email=self.user.email, course_id=self.course_id)
CourseEnrollment.enroll(self.user, self.course_id, 'verified')
ret_val = CourseEnrollment.unenroll(self.user, self.course_id)
self.assertFalse(ret_val)

View File

@@ -19,7 +19,7 @@ DEBUG = True
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 +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'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '')
CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/'
########################## USER API ########################

View File

@@ -144,9 +144,9 @@
% 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=gettext('You will be refunded for the amount you paid')">${_('Unregister')}</a>
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" onclick="document.getElementById('refund-info').innerHTML=gettext('You will be refunded 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=gettext('The refund deadline for this course has passed, so you will not receive money back')">${_('Unregister')}</a>
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" onclick="document.getElementById('refund-info').innerHTML=gettext('The refund deadline for this course has passed, so you will not receive a refund.')">${_('Unregister')}</a>
% endif
% if show_email_settings: