diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index b6849cd07bdf0e6dd4556decfc1a50eab703b15f..8e475d1ec1c13c1593e60f5b7f15efed87ad71f3 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -2,7 +2,7 @@ Add and create new modes for running courses on this particular LMS """ import pytz -from datetime import datetime, date +from datetime import datetime from django.db import models from collections import namedtuple @@ -101,17 +101,6 @@ class CourseMode(models.Model): modes = cls.modes_for_course(course_id) 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): return u"{} : {}, min={}, prices={}".format( self.course_id, self.mode_slug, self.min_price, self.suggested_prices diff --git a/common/djangoapps/course_modes/tests/factories.py b/common/djangoapps/course_modes/tests/factories.py index 72f30bf3832a8bc0713990871fa3f8618fc4aca4..3e35b2f05c4b211daf9fe544371b413960af54c0 100644 --- a/common/djangoapps/course_modes/tests/factories.py +++ b/common/djangoapps/course_modes/tests/factories.py @@ -1,6 +1,5 @@ from course_modes.models import CourseMode from factory import DjangoModelFactory -import datetime # Factories don't have __init__ methods, and are self documenting # pylint: disable=W0232 @@ -12,4 +11,3 @@ class CourseModeFactory(DjangoModelFactory): mode_display_name = 'audit course' min_price = 0 currency = 'usd' - expiration_date = datetime.date(1990, 1, 1) diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 36b1e72bdd00398bb2287a86aaf020ec3caabc47..7f09fbf7ccd40eeb8fb40ef741ed5832fb4d94fd 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -5,7 +5,7 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ -from datetime import datetime, date, timedelta +from datetime import datetime, timedelta import pytz from django.test import TestCase @@ -20,7 +20,6 @@ class CourseModeModelTest(TestCase): def setUp(self): self.course_id = 'TestCourse' CourseMode.objects.all().delete() - #todo use different default date def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices='', currency='usd'): """ @@ -113,9 +112,3 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course('second_test_course') 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)) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index ed8237870ffc1ac62e36c8dd3b8e162860a973e1..6c802977d095ebac6acaf602320c66d95417e71b 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -35,6 +35,7 @@ from student.tests.factories import UserFactory, CourseModeFactory from student.tests.test_email import mock_render_to_string import shoppingcart +from shoppingcart.models import CertificateItem COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' @@ -435,15 +436,20 @@ class CertificateItemTest(ModuleStoreTestCase): COURSE_ORG = "EDX" def setUp(self): - # Create course + # Create course, user, and enroll them as a verified student 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) 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): request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id, 'enrollment_action': 'unenroll'}) request.user = self.user response = change_enrollment(request) 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') + diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 6b30927c46293d408074ed9137058b0a6bac3b02..46ef470fb40f078593692ce8d25f1c2d496377a6 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -2,7 +2,6 @@ Student Views """ import datetime -from datetime import date import json import logging import random @@ -51,6 +50,8 @@ from verify_student.models import SoftwareSecurePhotoVerification from certificates.models import CertificateStatuses, certificate_status_for_student +from shoppingcart.models import CertificateItem + from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore @@ -66,7 +67,6 @@ import external_auth.views from bulk_email.models import Optout, CourseAuthorization import shoppingcart -from shoppingcart.models import (Order, OrderItem, CertificateItem) import track.views @@ -302,7 +302,6 @@ def dashboard(request): # exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. courses = [] - refund_status = [] for enrollment in CourseEnrollment.enrollments_for_user(user): try: courses.append((course_from_id(enrollment.course_id), enrollment)) @@ -343,7 +342,7 @@ def dashboard(request): verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) 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 external_auth_map = None @@ -351,6 +350,7 @@ def dashboard(request): external_auth_map = ExternalAuthMap.objects.get(user=user) except ExternalAuthMap.DoesNotExist: pass + context = {'courses': courses, 'course_optouts': course_optouts, 'message': message, @@ -432,8 +432,6 @@ def change_enrollment(request): .format(user.username, course_id)) return HttpResponseBadRequest(_("Course id is invalid")) - course = course_from_id(course_id) - if not has_access(user, course, 'enroll'): return HttpResponseBadRequest(_("Enrollment is closed")) @@ -475,41 +473,39 @@ def change_enrollment(request): elif action == "unenroll": try: course = course_from_id(course_id) - 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")) - - course = course_from_id(course_id) - verified = CourseEnrollment.enrollment_mode_for_user(user, course_id) - # did they sign up for verified certs? - if(verified): - - # If the user is allowed a refund, do so - if has_access(user, course, 'refund'): - subject = _("[Refund] User-Requested Refund") - # todo: make this reference templates/student/refund_email.html - 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)) + enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, course_id) + + # did they sign up for verified certs? + if(enrollment_mode=='verified'): + # If the user is allowed a refund, do so + if has_access(user, course, 'refund'): + subject = _("[Refund] User-Requested Refund") + # todo: make this reference templates/student/refund_email.html + 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) - - 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() + 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")) + 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: return HttpResponseBadRequest(_("Enrollment action is invalid")) @@ -924,7 +920,7 @@ def create_account(request, post_override=None): subject = ''.join(subject.splitlines()) 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')): try: if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'): diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index e8b527766d0a5e4fe69fc2c8300c402d1244e010..980e86f27dc3e780b7a47ca9b911027cce222072 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -2,7 +2,7 @@ Ideally, it will be the only place that needs to know about any special settings like DISABLE_START_DATES""" import logging -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta from functools import partial from django.conf import settings diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 22dd7e25fb61f7e8843bc09f580f8f7ee1dbf8f2..bd4f6e98410934e34246048f2b5a84ffe2116eb3 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -114,12 +114,12 @@ class AccessTestCase(TestCase): one_day_extra = datetime.timedelta(days=1) # 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')) - 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')) # 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')) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 761290d557438298d5b15d2667f94fe22d5cfeca..77d6d7898a3952ddb15f83c54b694e7c07c17c17 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -9,7 +9,7 @@ from boto.exception import BotoServerError # this is a super-class of SESError from django.db import models 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.contrib.auth.models import User from django.utils.translation import ugettext as _ @@ -405,18 +405,21 @@ class CertificateItem(OrderItem): @classmethod 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: - 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' - # todo return success + target_cert.save() return target_cert - except MultipleObjectsReturned: - # 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 + except IndexError or ObjectDoesNotExist: log.exception("No certificate found") # handle the exception diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 14d136853aa450ef3ac80f881e3753ef3286541d..bf96878f134681a2a8e5697a9fee612b87a9477a 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -368,7 +368,6 @@ class CertificateItemTest(ModuleStoreTestCase): cart = Order.get_cart_for_user(user=self.user) CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') cart.purchase() - enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) # now that it's there, let's try refunding it order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id) self.assertEquals(order.status, 'refunded') @@ -383,5 +382,4 @@ class CertificateItemTest(ModuleStoreTestCase): cart = Order.get_cart_for_user(user=self.user) CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') cart.purchase() - enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) self.assertRaises(MultipleObjectsReturned)