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 ...@@ -11,7 +11,6 @@ from django.db.models import Q
Mode = namedtuple('Mode', ['slug', 'name', 'min_price', 'suggested_prices', 'currency', 'expiration_date']) Mode = namedtuple('Mode', ['slug', 'name', 'min_price', 'suggested_prices', 'currency', 'expiration_date'])
class CourseMode(models.Model): class CourseMode(models.Model):
""" """
We would like to offer a course in a variety of modes. We would like to offer a course in a variety of modes.
...@@ -72,8 +71,8 @@ class CourseMode(models.Model): ...@@ -72,8 +71,8 @@ class CourseMode(models.Model):
@classmethod @classmethod
def modes_for_course_dict(cls, course_id): def modes_for_course_dict(cls, course_id):
""" """
Returns the modes for a particular course as a dictionary with Returns the non-expired modes for a particular course as a
the mode slug as the key dictionary with the mode slug as the key
""" """
return {mode.slug: mode for mode in cls.modes_for_course(course_id)} return {mode.slug: mode for mode in cls.modes_for_course(course_id)}
...@@ -82,6 +81,8 @@ class CourseMode(models.Model): ...@@ -82,6 +81,8 @@ class CourseMode(models.Model):
""" """
Returns the mode for the course corresponding to mode_slug. 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 If this particular mode is not set for the course, returns None
""" """
modes = cls.modes_for_course(course_id) modes = cls.modes_for_course(course_id)
...@@ -95,7 +96,8 @@ class CourseMode(models.Model): ...@@ -95,7 +96,8 @@ class CourseMode(models.Model):
@classmethod @classmethod
def min_course_price_for_currency(cls, course_id, currency): 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 If there is no mode found, will return the price of DEFAULT_MODE, which is 0
""" """
modes = cls.modes_for_course(course_id) modes = cls.modes_for_course(course_id)
......
...@@ -11,3 +11,4 @@ class CourseModeFactory(DjangoModelFactory): ...@@ -11,3 +11,4 @@ class CourseModeFactory(DjangoModelFactory):
mode_display_name = 'audit course' mode_display_name = 'audit course'
min_price = 0 min_price = 0
currency = 'usd' currency = 'usd'
expiration_date = None
...@@ -31,7 +31,7 @@ class CourseModeModelTest(TestCase): ...@@ -31,7 +31,7 @@ class CourseModeModelTest(TestCase):
mode_slug=mode_slug, mode_slug=mode_slug,
min_price=min_price, min_price=min_price,
suggested_prices=suggested_prices, suggested_prices=suggested_prices,
currency=currency currency=currency,
) )
def test_modes_for_course_empty(self): def test_modes_for_course_empty(self):
......
...@@ -17,18 +17,20 @@ import json ...@@ -17,18 +17,20 @@ import json
import logging import logging
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
import django.dispatch
from django.forms import ModelForm, forms from django.forms import ModelForm, forms
from course_modes.models import CourseMode
import comment_client as cc import comment_client as cc
from pytz import UTC from pytz import UTC
unenroll_done = django.dispatch.Signal(providing_args=["course_enrollment"])
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
...@@ -825,6 +827,7 @@ class CourseEnrollment(models.Model): ...@@ -825,6 +827,7 @@ class CourseEnrollment(models.Model):
record = CourseEnrollment.objects.get(user=user, course_id=course_id) record = CourseEnrollment.objects.get(user=user, course_id=course_id)
record.is_active = False record.is_active = False
record.save() record.save()
unenroll_done.send(sender=cls, course_enrollment=record)
except cls.DoesNotExist: except cls.DoesNotExist:
err_msg = u"Tried to unenroll student {} from {} but they were not enrolled" err_msg = u"Tried to unenroll student {} from {} but they were not enrolled"
log.error(err_msg.format(user, course_id)) log.error(err_msg.format(user, course_id))
...@@ -924,6 +927,18 @@ class CourseEnrollment(models.Model): ...@@ -924,6 +927,18 @@ class CourseEnrollment(models.Model):
self.is_active = False self.is_active = False
self.save() 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): class CourseEnrollmentAllowed(models.Model):
""" """
......
...@@ -256,6 +256,22 @@ class DashboardTest(TestCase): ...@@ -256,6 +256,22 @@ class DashboardTest(TestCase):
self.assertFalse(course_mode_info['show_upsell']) self.assertFalse(course_mode_info['show_upsell'])
self.assertIsNone(course_mode_info['days_for_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): class EnrollInCourseTest(TestCase):
"""Tests enrolling and unenrolling in courses.""" """Tests enrolling and unenrolling in courses."""
......
...@@ -47,7 +47,6 @@ from student.models import ( ...@@ -47,7 +47,6 @@ from student.models import (
from student.forms import PasswordResetFormNoActive from student.forms import PasswordResetFormNoActive
from verify_student.models import SoftwareSecurePhotoVerification from verify_student.models import SoftwareSecurePhotoVerification
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -73,6 +72,7 @@ from pytz import UTC ...@@ -73,6 +72,7 @@ from pytz import UTC
from util.json_request import JsonResponse from util.json_request import JsonResponse
log = logging.getLogger("mitx.student") log = logging.getLogger("mitx.student")
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
...@@ -296,13 +296,13 @@ def complete_course_mode_info(course_id, enrollment): ...@@ -296,13 +296,13 @@ def complete_course_mode_info(course_id, enrollment):
def dashboard(request): def dashboard(request):
user = request.user user = request.user
# Build our courses list for the user, but ignore any courses that no longer # Build our (course, enorllment) list for the user, but ignore any courses that no
# exist (because the course IDs have changed). Still, we don't delete those # longer exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu. # enrollments, because it could have been a data push snafu.
courses = [] course_enrollment_pairs = []
for enrollment in CourseEnrollment.enrollments_for_user(user): for enrollment in CourseEnrollment.enrollments_for_user(user):
try: try:
courses.append((course_from_id(enrollment.course_id), enrollment)) course_enrollment_pairs.append((course_from_id(enrollment.course_id), enrollment))
except ItemNotFoundError: except ItemNotFoundError:
log.error("User {0} enrolled in non-existent course {1}" log.error("User {0} enrolled in non-existent course {1}"
.format(user.username, enrollment.course_id)) .format(user.username, enrollment.course_id))
...@@ -321,22 +321,27 @@ def dashboard(request): ...@@ -321,22 +321,27 @@ def dashboard(request):
staff_access = True staff_access = True
errored_courses = modulestore().get_errored_courses() 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')) if has_access(request.user, course, 'load'))
course_modes = {course.id: complete_course_mode_info(course.id, enrollment) 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 courses} 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 # only show email settings for Mongo course and when bulk email is turned on
show_email_settings_for = frozenset( 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 settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and
modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE and modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE and
CourseAuthorization.instructor_email_enabled(course.id) CourseAuthorization.instructor_email_enabled(course.id)
) )
) )
# Verification Attempts # Verification Attempts
verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) 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 # get info w.r.t ExternalAuthMap
external_auth_map = None external_auth_map = None
try: try:
...@@ -344,7 +349,7 @@ def dashboard(request): ...@@ -344,7 +349,7 @@ def dashboard(request):
except ExternalAuthMap.DoesNotExist: except ExternalAuthMap.DoesNotExist:
pass pass
context = {'courses': courses, context = {'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts, 'course_optouts': course_optouts,
'message': message, 'message': message,
'external_auth_map': external_auth_map, 'external_auth_map': external_auth_map,
...@@ -356,6 +361,7 @@ def dashboard(request): ...@@ -356,6 +361,7 @@ def dashboard(request):
'show_email_settings_for': show_email_settings_for, 'show_email_settings_for': show_email_settings_for,
'verification_status': verification_status, 'verification_status': verification_status,
'verification_msg': verification_msg, 'verification_msg': verification_msg,
'show_refund_option_for': show_refund_option_for,
} }
return render_to_response('dashboard.html', context) return render_to_response('dashboard.html', context)
...@@ -463,20 +469,17 @@ def change_enrollment(request): ...@@ -463,20 +469,17 @@ def change_enrollment(request):
) )
elif action == "unenroll": elif action == "unenroll":
try: if not CourseEnrollment.is_enrolled(user, course_id):
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:
return HttpResponseBadRequest(_("You are not enrolled in this course")) 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: else:
return HttpResponseBadRequest(_("Enrollment action is invalid")) return HttpResponseBadRequest(_("Enrollment action is invalid"))
...@@ -891,7 +894,7 @@ def create_account(request, post_override=None): ...@@ -891,7 +894,7 @@ def create_account(request, post_override=None):
subject = ''.join(subject.splitlines()) subject = ''.join(subject.splitlines())
message = render_to_string('emails/activation_email.txt', d) 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')): if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING')):
try: try:
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'): if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
...@@ -1512,4 +1515,4 @@ def change_email_settings(request): ...@@ -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)) 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') 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
from mock import Mock from mock import Mock
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
import courseware.access as access import courseware.access as access
from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
from .factories import CourseEnrollmentAllowedFactory from .factories import CourseEnrollmentAllowedFactory
import datetime import datetime
from django.utils.timezone import UTC import pytz
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class AccessTestCase(TestCase): class AccessTestCase(TestCase):
"""
Tests for the various access controls on the student dashboard
"""
def test__has_global_staff_access(self): def test__has_global_staff_access(self):
u = Mock(is_staff=False) u = Mock(is_staff=False)
self.assertFalse(access._has_global_staff_access(u)) self.assertFalse(access._has_global_staff_access(u))
...@@ -71,7 +77,7 @@ class AccessTestCase(TestCase): ...@@ -71,7 +77,7 @@ class AccessTestCase(TestCase):
# TODO: override DISABLE_START_DATES and test the start date branch of the method # TODO: override DISABLE_START_DATES and test the start date branch of the method
u = Mock() u = Mock()
d = 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 # Always returns true because DISABLE_START_DATES is set in test.py
self.assertTrue(access._has_access_descriptor(u, d, 'load')) self.assertTrue(access._has_access_descriptor(u, d, 'load'))
...@@ -79,8 +85,8 @@ class AccessTestCase(TestCase): ...@@ -79,8 +85,8 @@ class AccessTestCase(TestCase):
def test__has_access_course_desc_can_enroll(self): def test__has_access_course_desc_can_enroll(self):
u = Mock() u = Mock()
yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1) yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1)
tomorrow = datetime.datetime.now(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='') c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow, enrollment_domain='')
# User can enroll if it is between the start and end dates # User can enroll if it is between the start and end dates
......
...@@ -7,6 +7,7 @@ from model_utils.managers import InheritanceManager ...@@ -7,6 +7,7 @@ from model_utils.managers import InheritanceManager
from collections import namedtuple from collections import namedtuple
from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors 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.db import models
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
...@@ -23,7 +24,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -23,7 +24,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from course_modes.models import CourseMode from course_modes.models import CourseMode
from mitxmako.shortcuts import render_to_string from mitxmako.shortcuts import render_to_string
from student.views import course_from_id 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 verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
...@@ -34,7 +36,7 @@ log = logging.getLogger("shoppingcart") ...@@ -34,7 +36,7 @@ log = logging.getLogger("shoppingcart")
ORDER_STATUSES = ( ORDER_STATUSES = (
('cart', 'cart'), ('cart', 'cart'),
('purchased', 'purchased'), ('purchased', 'purchased'),
('refunded', 'refunded'), # Not used for now ('refunded', 'refunded'),
) )
# we need a tuple to represent the primary key of various OrderItem subclasses # we need a tuple to represent the primary key of various OrderItem subclasses
...@@ -398,6 +400,49 @@ class CertificateItem(OrderItem): ...@@ -398,6 +400,49 @@ class CertificateItem(OrderItem):
course_enrollment = models.ForeignKey(CourseEnrollment) course_enrollment = models.ForeignKey(CourseEnrollment)
mode = models.SlugField() 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 @classmethod
@transaction.commit_on_success @transaction.commit_on_success
def add_to_order(cls, order, course_id, cost, mode, currency='usd'): def add_to_order(cls, order, course_id, cost, mode, currency='usd'):
......
...@@ -20,6 +20,8 @@ from student.tests.factories import UserFactory ...@@ -20,6 +20,8 @@ from student.tests.factories import UserFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from course_modes.models import CourseMode from course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException from shoppingcart.exceptions import PurchasedCallbackException
import pytz
import datetime
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
...@@ -360,3 +362,89 @@ class CertificateItemTest(ModuleStoreTestCase): ...@@ -360,3 +362,89 @@ class CertificateItemTest(ModuleStoreTestCase):
cert_item = CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') cert_item = CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor')
self.assertEquals(cert_item.single_item_receipt_template, self.assertEquals(cert_item.single_item_receipt_template,
'shoppingcart/receipt.html') '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 @@ ...@@ -183,14 +183,15 @@
<h2>${_("Current Courses")}</h2> <h2>${_("Current Courses")}</h2>
</header> </header>
% if len(courses) > 0: % if len(course_enrollment_pairs) > 0:
<ul class="listing-courses"> <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) %> <% show_courseware_link = (course.id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(course.id) %> <% cert_status = cert_statuses.get(course.id) %>
<% show_email_settings = (course.id in show_email_settings_for) %> <% show_email_settings = (course.id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %> <% 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 % endfor
</ul> </ul>
...@@ -244,26 +245,7 @@ ...@@ -244,26 +245,7 @@
</div> </div>
</section> </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"> <section id="password_reset_complete" class="modal" aria-hidden="true">
<div class="inner-wrapper" role="dialog" aria-labelledby="password-reset-email"> <div class="inner-wrapper" role="dialog" aria-labelledby="password-reset-email">
...@@ -341,3 +323,22 @@ ...@@ -341,3 +323,22 @@
</div> </div>
</div> </div>
</section> </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 _ %> <%! from django.utils.translation import ugettext as _ %>
<%! <%!
...@@ -141,12 +141,20 @@ ...@@ -141,12 +141,20 @@
% endif % endif
% 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: % 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> <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 % endif
</section> </section>
</article> </article>
</li> </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