Commit fcab46b1 by Julia Hansbrough

Refactored to use signals; full test coverage

parent 63940141
...@@ -17,7 +17,6 @@ import json ...@@ -17,7 +17,6 @@ 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
...@@ -29,6 +28,9 @@ from django.forms import ModelForm, forms ...@@ -29,6 +28,9 @@ from django.forms import ModelForm, forms
import comment_client as cc import comment_client as cc
from pytz import UTC from pytz import UTC
import django.dispatch
verified_unenroll_done = django.dispatch.Signal(providing_args=["user", "user_email", "course_id"])
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
......
...@@ -35,7 +35,8 @@ from student.tests.factories import UserFactory, CourseModeFactory ...@@ -35,7 +35,8 @@ from student.tests.factories import UserFactory, CourseModeFactory
from student.tests.test_email import mock_render_to_string from student.tests.test_email import mock_render_to_string
import shoppingcart import shoppingcart
from shoppingcart.models import CertificateItem
from course_modes.models import CourseMode
COURSE_1 = 'edX/toy/2012_Fall' COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012' COURSE_2 = 'edx/full/6.002_Spring_2012'
...@@ -426,9 +427,9 @@ class PaidRegistrationTest(ModuleStoreTestCase): ...@@ -426,9 +427,9 @@ class PaidRegistrationTest(ModuleStoreTestCase):
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @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 # test data
COURSE_SLUG = "100" COURSE_SLUG = "100"
...@@ -437,19 +438,36 @@ class CertificateItemTest(ModuleStoreTestCase): ...@@ -437,19 +438,36 @@ class CertificateItemTest(ModuleStoreTestCase):
def setUp(self): def setUp(self):
# Create course, user, and enroll them as a verified student # 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.req_factory = RequestFactory()
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course) course_enrollment = CourseEnrollment.create_enrollment(self.user, self.course_id, 'verified', is_active=True)
self.user = User.objects.create(username="test", email="test@test.org") course_enrollment.save()
CourseEnrollment.enroll(self.user, self.course.id, mode='verified')
# Student is verified and paid; we should be able to refund them # Student is verified and paid; we should be able to refund them
def test_unenroll_and_refund(self): 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 request.user = self.user
response = change_enrollment(request) response = change_enrollment(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(CourseEnrollment.is_enrolled(self.user,self.course.id)) 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')
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 ( ...@@ -43,13 +43,12 @@ from student.models import (
TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange,
PendingEmailChange, CourseEnrollment, unique_id_for_user, PendingEmailChange, CourseEnrollment, unique_id_for_user,
get_testcenter_registration, CourseEnrollmentAllowed, UserStanding, get_testcenter_registration, CourseEnrollmentAllowed, UserStanding,
verified_unenroll_done
) )
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 shoppingcart.models import CertificateItem from shoppingcart.models import CertificateItem
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -75,6 +74,7 @@ from pytz import UTC ...@@ -75,6 +74,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")
...@@ -476,22 +476,11 @@ def change_enrollment(request): ...@@ -476,22 +476,11 @@ def change_enrollment(request):
enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, course_id) enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, course_id)
# did they sign up for verified certs? # 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 the user is allowed a refund, do so
if has_access(user, course, 'refund'): if has_access(user, course, 'refund'):
subject = _("[Refund] User-Requested Refund") # triggers the callback to mark the certificate as refunded
# todo: make this reference templates/student/refund_email.html verified_unenroll_done.send(sender=request, user=user, user_email=user.email, course_id=course_id)
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)
CourseEnrollment.unenroll(user, course_id) CourseEnrollment.unenroll(user, course_id)
org, course_num, run = course_id.split("/") org, course_num, run = course_id.split("/")
dog_stats_api.increment( dog_stats_api.increment(
......
...@@ -108,18 +108,18 @@ class AccessTestCase(TestCase): ...@@ -108,18 +108,18 @@ class AccessTestCase(TestCase):
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed # Non-staff cannot enroll outside the open enrollment period if not specifically allowed
def test__has_access_refund(self): def test__has_access_refund(self):
u = Mock() user = Mock()
today = datetime.datetime.now(UTC()) today = datetime.datetime.now(UTC())
grace_period = datetime.timedelta(days=14) grace_period = datetime.timedelta(days=14)
one_day_extra = datetime.timedelta(days=1) one_day_extra = datetime.timedelta(days=1)
# User is allowed to receive refund if it is within two weeks of course start date # 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') course = Mock(enrollment_start=(today - one_day_extra), id='edX/tests/Whenever')
self.assertTrue(access._has_access_course_desc(u, c, 'refund')) self.assertTrue(access._has_access_course_desc(user, course, 'refund'))
c = Mock(enrollment_start=(today - grace_period), id='edX/test/Whenever') course = Mock(enrollment_start=(today - grace_period), id='edX/test/Whenever')
self.assertTrue(access._has_access_course_desc(u, c, 'refund')) self.assertTrue(access._has_access_course_desc(user, course, 'refund'))
# After two weeks, user may no longer receive a 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') course = Mock(enrollment_start=(today - grace_period - one_day_extra), id='edX/test/Whenever')
self.assertFalse(access._has_access_course_desc(u, c, 'refund')) self.assertFalse(access._has_access_course_desc(user, course, 'refund'))
...@@ -7,6 +7,9 @@ from model_utils.managers import InheritanceManager ...@@ -7,6 +7,9 @@ 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 student.models import verified_unenroll_done
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
...@@ -22,7 +25,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -22,7 +25,9 @@ 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.models import CourseEnrollment from student.models import CourseEnrollment
from verify_student.models import SoftwareSecurePhotoVerification from verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
...@@ -40,12 +45,6 @@ ORDER_STATUSES = ( ...@@ -40,12 +45,6 @@ ORDER_STATUSES = (
OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk']) # pylint: disable=C0103 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): class Order(models.Model):
""" """
This is the model for an order. Before purchase, an Order and its related OrderItems are used This is the model for an order. Before purchase, an Order and its related OrderItems are used
...@@ -404,24 +403,38 @@ class CertificateItem(OrderItem): ...@@ -404,24 +403,38 @@ class CertificateItem(OrderItem):
mode = models.SlugField() mode = models.SlugField()
@classmethod @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 When a CourseEnrollment object whose mode is 'verified' has its is_active field set to false (i.e. when a student
certificate's status to "refunded", save that result, and return the refunded certificate. is unenrolled), this callback ensures that the associated CertificateItem is marked as refunded, and that an
appropriate email is sent to billing.
Note the actual mechanics of refunding money occurs elsewhere; this simply changes the relevant certificate's
status for the refund.
""" """
try: 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) # 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 = target_certs[0]
target_cert.status = 'refunded' target_cert.status = 'refunded'
target_cert.save() 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 return target_cert
except IndexError or ObjectDoesNotExist:
except IndexError:
log.exception("No certificate found") log.exception("No certificate found")
# handle the exception raise IndexError
@classmethod @classmethod
@transaction.commit_on_success @transaction.commit_on_success
......
...@@ -17,10 +17,9 @@ from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE ...@@ -17,10 +17,9 @@ from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration, from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration,
OrderItemSubclassPK) OrderItemSubclassPK)
from student.tests.factories import UserFactory 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 course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException from shoppingcart.exceptions import PurchasedCallbackException
from django.core.exceptions import (ObjectDoesNotExist, MultipleObjectsReturned)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
...@@ -362,24 +361,17 @@ class CertificateItemTest(ModuleStoreTestCase): ...@@ -362,24 +361,17 @@ class CertificateItemTest(ModuleStoreTestCase):
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_single_cert(self): def test_refund_cert_callback(self):
# enroll and buy; dup from test_existing_enrollment # enroll and buy; dup from test_existing_enrollment
CourseEnrollment.enroll(self.user, self.course_id) CourseEnrollment.enroll(self.user, self.course_id)
cart = Order.get_cart_for_user(user=self.user) cart = Order.get_cart_for_user(user=self.user)
CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified')
cart.purchase() cart.purchase()
# now that it's there, let's try refunding it # now that it's there, let's try refunding it
order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id) verified_unenroll_done.send(sender=self, user=self.user, user_email=self.user.email, course_id=self.course_id)
self.assertEquals(order.status, 'refunded') 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): def test_refund_cert_no_cert_exists(self):
order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id) with self.assertRaises(IndexError):
self.assertRaises(ObjectDoesNotExist) verified_unenroll_done.send(sender=self, user=self.user, user_email=self.user.email, course_id=self.course_id)
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)
...@@ -20,7 +20,6 @@ USE_I18N = True ...@@ -20,7 +20,6 @@ USE_I18N = True
TEMPLATE_DEBUG = True TEMPLATE_DEBUG = True
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
MITX_FEATURES['DISABLE_START_DATES'] = False MITX_FEATURES['DISABLE_START_DATES'] = False
MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up 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: ...@@ -270,7 +269,7 @@ if SEGMENT_IO_LMS_KEY:
CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = os.environ.get('CYBERSOURCE_SHARED_SECRET', '') 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']['MERCHANT_ID'] = os.environ.get('CYBERSOURCE_MERCHANT_ID', '')
CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '') 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 ######################## ########################## USER API ########################
......
...@@ -141,9 +141,13 @@ ...@@ -141,9 +141,13 @@
% 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('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: % 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>
...@@ -158,20 +162,12 @@ ...@@ -158,20 +162,12 @@
<section id="unenroll-modal" class="modal unenroll-modal" aria-hidden="true"> <section id="unenroll-modal" class="modal unenroll-modal" aria-hidden="true">
<div class="inner-wrapper" role="alertdialog" aria-labelledy="unenrollment-modal-title"> <div class="inner-wrapper" role="alertdialog" aria-labelledy="unenrollment-modal-title">
<button class="close-modal">&#10005; <span class="sr">${_('Close Modal')}</span></button> <button class="close-modal">&#10005; <span class="sr">${_('Close Modal')}</span></button>
<header> <header>
% if enrollment.mode != "verified": <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>
<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> refund
% 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
<hr/> <hr/>
</header> </header>
<div id="unenroll_error" class="modal-form-error"></div> <div id="unenroll_error" class="modal-form-error"></div>
<form id="unenroll_form" method="post" data-remote="true" action="${reverse('change_enrollment')}"> <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="course_id" id="unenroll_course_id" type="hidden" />
<input name="enrollment_action" type="hidden" value="unenroll" /> <input name="enrollment_action" type="hidden" value="unenroll" />
......
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