Commit 63940141 by Julia Hansbrough

End-to-end refunding with tests

parent 045e69f3
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Add and create new modes for running courses on this particular LMS Add and create new modes for running courses on this particular LMS
""" """
import pytz import pytz
from datetime import datetime, date from datetime import datetime
from django.db import models from django.db import models
from collections import namedtuple from collections import namedtuple
...@@ -101,17 +101,6 @@ class CourseMode(models.Model): ...@@ -101,17 +101,6 @@ class CourseMode(models.Model):
modes = cls.modes_for_course(course_id) modes = cls.modes_for_course(course_id)
return min(mode.min_price for mode in modes if mode.currency == currency) return min(mode.min_price for mode in modes if mode.currency == currency)
@classmethod
def refund_expiration_date(cls, course_id, mode_slug):
"""
Returns the expiration date for verified certificate refunds. After this date, refunds are
no longer possible. Note that this is currently set to be identical to the expiration date for
verified cert signups, but this could be changed in the future
"""
print "TODO fix this"
return date(1990, 1, 1)
#return cls.mode_for_course(course_id,mode_slug).expiration_date
def __unicode__(self): def __unicode__(self):
return u"{} : {}, min={}, prices={}".format( return u"{} : {}, min={}, prices={}".format(
self.course_id, self.mode_slug, self.min_price, self.suggested_prices self.course_id, self.mode_slug, self.min_price, self.suggested_prices
......
from course_modes.models import CourseMode from course_modes.models import CourseMode
from factory import DjangoModelFactory from factory import DjangoModelFactory
import datetime
# Factories don't have __init__ methods, and are self documenting # Factories don't have __init__ methods, and are self documenting
# pylint: disable=W0232 # pylint: disable=W0232
...@@ -12,4 +11,3 @@ class CourseModeFactory(DjangoModelFactory): ...@@ -12,4 +11,3 @@ 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 = datetime.date(1990, 1, 1)
...@@ -5,7 +5,7 @@ when you run "manage.py test". ...@@ -5,7 +5,7 @@ when you run "manage.py test".
Replace this with more appropriate tests for your application. Replace this with more appropriate tests for your application.
""" """
from datetime import datetime, date, timedelta from datetime import datetime, timedelta
import pytz import pytz
from django.test import TestCase from django.test import TestCase
...@@ -20,7 +20,6 @@ class CourseModeModelTest(TestCase): ...@@ -20,7 +20,6 @@ class CourseModeModelTest(TestCase):
def setUp(self): def setUp(self):
self.course_id = 'TestCourse' self.course_id = 'TestCourse'
CourseMode.objects.all().delete() CourseMode.objects.all().delete()
#todo use different default date
def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices='', currency='usd'): def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices='', currency='usd'):
""" """
...@@ -113,9 +112,3 @@ class CourseModeModelTest(TestCase): ...@@ -113,9 +112,3 @@ class CourseModeModelTest(TestCase):
modes = CourseMode.modes_for_course('second_test_course') modes = CourseMode.modes_for_course('second_test_course')
self.assertEqual([CourseMode.DEFAULT_MODE], modes) self.assertEqual([CourseMode.DEFAULT_MODE], modes)
def test_refund_expiration_date(self):
self.create_mode('verified', 'Verified Certificate')
modes = CourseMode.modes_for_course(self.course_id)
mode = Mode(u'verified', u'Verified Certificate', 0, '', 'usd')
self.assertEqual(CourseMode.refund_expiration_date(self.course_id, 'verified'), date(1990, 1, 1))
...@@ -35,6 +35,7 @@ from student.tests.factories import UserFactory, CourseModeFactory ...@@ -35,6 +35,7 @@ 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
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'
...@@ -435,15 +436,20 @@ class CertificateItemTest(ModuleStoreTestCase): ...@@ -435,15 +436,20 @@ class CertificateItemTest(ModuleStoreTestCase):
COURSE_ORG = "EDX" COURSE_ORG = "EDX"
def setUp(self): def setUp(self):
# Create course # Create course, user, and enroll them as a verified student
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.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course) self.assertIsNotNone(self.course)
self.user = User.objects.create(username="test", email="test@test.org") self.user = User.objects.create(username="test", email="test@test.org")
CourseEnrollment.enroll(self.user, self.course.id, mode='verified')
# 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)
# add more later; see if this even works 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')
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
Student Views Student Views
""" """
import datetime import datetime
from datetime import date
import json import json
import logging import logging
import random import random
...@@ -51,6 +50,8 @@ from verify_student.models import SoftwareSecurePhotoVerification ...@@ -51,6 +50,8 @@ 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 xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -66,7 +67,6 @@ import external_auth.views ...@@ -66,7 +67,6 @@ import external_auth.views
from bulk_email.models import Optout, CourseAuthorization from bulk_email.models import Optout, CourseAuthorization
import shoppingcart import shoppingcart
from shoppingcart.models import (Order, OrderItem, CertificateItem)
import track.views import track.views
...@@ -302,7 +302,6 @@ def dashboard(request): ...@@ -302,7 +302,6 @@ def dashboard(request):
# exist (because the course IDs have changed). Still, we don't delete those # 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 = [] courses = []
refund_status = []
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)) courses.append((course_from_id(enrollment.course_id), enrollment))
...@@ -343,7 +342,7 @@ def dashboard(request): ...@@ -343,7 +342,7 @@ def dashboard(request):
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 courses show_refund_option_for = frozenset(course.id for course, _enrollment in courses
if (has_access(request.user, course, 'refund') and (_enrollment.mode == "verified"))) if (has_access(request.user, course, 'refund') and (_enrollment.mode == "verified")))
# get info w.r.t ExternalAuthMap # get info w.r.t ExternalAuthMap
external_auth_map = None external_auth_map = None
...@@ -351,6 +350,7 @@ def dashboard(request): ...@@ -351,6 +350,7 @@ def dashboard(request):
external_auth_map = ExternalAuthMap.objects.get(user=user) external_auth_map = ExternalAuthMap.objects.get(user=user)
except ExternalAuthMap.DoesNotExist: except ExternalAuthMap.DoesNotExist:
pass pass
context = {'courses': courses, context = {'courses': courses,
'course_optouts': course_optouts, 'course_optouts': course_optouts,
'message': message, 'message': message,
...@@ -432,8 +432,6 @@ def change_enrollment(request): ...@@ -432,8 +432,6 @@ def change_enrollment(request):
.format(user.username, course_id)) .format(user.username, course_id))
return HttpResponseBadRequest(_("Course id is invalid")) return HttpResponseBadRequest(_("Course id is invalid"))
course = course_from_id(course_id)
if not has_access(user, course, 'enroll'): if not has_access(user, course, 'enroll'):
return HttpResponseBadRequest(_("Enrollment is closed")) return HttpResponseBadRequest(_("Enrollment is closed"))
...@@ -475,41 +473,39 @@ def change_enrollment(request): ...@@ -475,41 +473,39 @@ def change_enrollment(request):
elif action == "unenroll": elif action == "unenroll":
try: try:
course = course_from_id(course_id) course = course_from_id(course_id)
except ItemNotFoundError: enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, course_id)
log.warning("User {0} tried to unenroll from non-existent course {1}"
.format(user.username, course_id)) # did they sign up for verified certs?
return HttpResponseBadRequest(_("Course id is invalid")) if(enrollment_mode=='verified'):
# If the user is allowed a refund, do so
course = course_from_id(course_id) if has_access(user, course, 'refund'):
verified = CourseEnrollment.enrollment_mode_for_user(user, course_id) subject = _("[Refund] User-Requested Refund")
# did they sign up for verified certs? # todo: make this reference templates/student/refund_email.html
if(verified): message = "Important info here."
to_email = [settings.PAYMENT_SUPPORT_EMAIL]
# If the user is allowed a refund, do so from_email = "support@edx.org"
if has_access(user, course, 'refund'): try:
subject = _("[Refund] User-Requested Refund") send_mail(subject, message, from_email, to_email, fail_silently=False)
# todo: make this reference templates/student/refund_email.html except:
message = "Important info here." log.warning('Unable to send reimbursement request to billing', exc_info=True)
to_email = [settings.PAYMENT_SUPPORT_EMAIL] js['value'] = _('Could not send reimbursement request.')
from_email = "support@edx.org" return HttpResponse(json.dumps(js))
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 # email has been sent, let's deal with the order now
CertificateItem.refund_cert(user, course_id) 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( "common.student.unenrollment",
"common.student.unenrollment", tags=["org:{0}".format(org),
tags=["org:{0}".format(org), "course:{0}".format(course_num),
"course:{0}".format(course_num), "run:{0}".format(run)]
"run:{0}".format(run)] )
) return HttpResponse()
return HttpResponse() except CourseEnrollment.DoesNotExist:
return HttpResponseBadRequest(_("You are not enrolled in this course"))
except ItemNotFoundError:
log.warning("User {0} tried to unenroll from non-existent course {1}".format(user.username, course_id))
return HttpResponseBadRequest(_("Course id is invalid"))
else: else:
return HttpResponseBadRequest(_("Enrollment action is invalid")) return HttpResponseBadRequest(_("Enrollment action is invalid"))
...@@ -924,7 +920,7 @@ def create_account(request, post_override=None): ...@@ -924,7 +920,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)
# don't send email if we are doing load testing or random user generation for some reason # dont 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'):
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Ideally, it will be the only place that needs to know about any special settings Ideally, it will be the only place that needs to know about any special settings
like DISABLE_START_DATES""" like DISABLE_START_DATES"""
import logging import logging
from datetime import datetime, timedelta, date from datetime import datetime, timedelta
from functools import partial from functools import partial
from django.conf import settings from django.conf import settings
......
...@@ -114,12 +114,12 @@ class AccessTestCase(TestCase): ...@@ -114,12 +114,12 @@ class AccessTestCase(TestCase):
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') c = 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(u, c, 'refund'))
c = Mock(enrollment_start=(today-grace_period), id='edX/test/Whenever') c = 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(u, c, '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') c = 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(u, c, 'refund'))
...@@ -9,7 +9,7 @@ from boto.exception import BotoServerError # this is a super-class of SESError ...@@ -9,7 +9,7 @@ from boto.exception import BotoServerError # this is a super-class of SESError
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, MultipleObjectsReturned) from django.core.exceptions import ObjectDoesNotExist
from django.core.mail import send_mail from django.core.mail import send_mail
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -405,18 +405,21 @@ class CertificateItem(OrderItem): ...@@ -405,18 +405,21 @@ class CertificateItem(OrderItem):
@classmethod @classmethod
def refund_cert(cls, target_user, target_course_id): def refund_cert(cls, target_user, target_course_id):
"""
When refunded, this should find a verified certificate purchase for target_user in target_course_id, change that
certificate's status to "refunded", save that result, and return the refunded certificate.
Note the actual mechanics of refunding money occurs elsewhere; this simply changes the relevant certificate's
status for the refund.
"""
try: try:
target_cert = CertificateItem.objects.get(course_id=target_course_id, user_id=target_user, status='purchased', mode='verified') # 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_cert = target_certs[0]
target_cert.status = 'refunded' target_cert.status = 'refunded'
# todo return success target_cert.save()
return target_cert return target_cert
except MultipleObjectsReturned: except IndexError or ObjectDoesNotExist:
# this seems like a thing that shouldn't happen
log.exception("Multiple entries for single verified cert found")
# but we can recover; select one item and refund it
# todo
except ObjectDoesNotExist:
# todo log properly
log.exception("No certificate found") log.exception("No certificate found")
# handle the exception # handle the exception
......
...@@ -368,7 +368,6 @@ class CertificateItemTest(ModuleStoreTestCase): ...@@ -368,7 +368,6 @@ class CertificateItemTest(ModuleStoreTestCase):
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()
enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id)
# 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) order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id)
self.assertEquals(order.status, 'refunded') self.assertEquals(order.status, 'refunded')
...@@ -383,5 +382,4 @@ class CertificateItemTest(ModuleStoreTestCase): ...@@ -383,5 +382,4 @@ class CertificateItemTest(ModuleStoreTestCase):
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()
enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id)
self.assertRaises(MultipleObjectsReturned) self.assertRaises(MultipleObjectsReturned)
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