Commit 1e2c0f50 by Julia Hansbrough

Merge pull request #1529 from edx/flowerhack/feature/refunds-for-certificates

Flowerhack/feature/refunds for certificates
parents 3256ec2d 448f5cc4
......@@ -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)
......
......@@ -11,3 +11,4 @@ class CourseModeFactory(DjangoModelFactory):
mode_display_name = 'audit course'
min_price = 0
currency = 'usd'
expiration_date = None
......@@ -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):
......
......@@ -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):
"""
......
......@@ -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."""
......
......@@ -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,9 +469,9 @@ def change_enrollment(request):
)
elif action == "unenroll":
try:
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",
......@@ -473,10 +479,7 @@ def change_enrollment(request):
"course:{0}".format(course_num),
"run:{0}".format(run)]
)
return HttpResponse()
except CourseEnrollment.DoesNotExist:
return HttpResponseBadRequest(_("You are not enrolled in this course"))
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'):
......
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
......
......@@ -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'):
......
......@@ -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)
......@@ -183,14 +183,15 @@
<h2>${_("Current Courses")}</h2>
</header>
% if len(courses) > 0:
% if len(course_enrollment_pairs) > 0:
<ul class="listing-courses">
% for course, enrollment in courses:
% for course, enrollment in course_enrollment_pairs:
<% show_courseware_link = (course.id in show_courseware_links_for) %>
<% 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">&#10005; <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">
......@@ -341,3 +323,22 @@
</div>
</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">&#10005; <span class="sr">${_('Close Modal')}</span></button>
<header>
<h2 id="unenrollment-modal-title">${_('<span id="track-info"></span> {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" />
<div class="submit">
<input name="submit" type="submit" value="${_('Unregister')}" />
</div>
</form>
</div>
</section>
\ No newline at end of file
<%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
<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('track-info').innerHTML='Are you sure you want to unregister from'; 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('track-info').innerHTML='Are you sure you want to unregister from the verified certificate track of'; 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('track-info').innerHTML='Are you sure you want to unregister from the verified certificate track of'; 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:
<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>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment