Commit 045e69f3 by Julia Hansbrough

Can check verified-ness and expiration date

parent b4895140
......@@ -2,7 +2,7 @@
Add and create new modes for running courses on this particular LMS
"""
import pytz
from datetime import datetime
from datetime import datetime, date
from django.db import models
from collections import namedtuple
......@@ -101,6 +101,17 @@ 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
......
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
......@@ -11,3 +12,4 @@ class CourseModeFactory(DjangoModelFactory):
mode_display_name = 'audit course'
min_price = 0
currency = 'usd'
expiration_date = datetime.date(1990, 1, 1)
......@@ -5,7 +5,7 @@ when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""
from datetime import datetime, timedelta
from datetime import datetime, date, timedelta
import pytz
from django.test import TestCase
......@@ -20,6 +20,7 @@ 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'):
"""
......@@ -31,7 +32,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):
......@@ -112,3 +113,9 @@ 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))
......@@ -422,3 +422,28 @@ class PaidRegistrationTest(ModuleStoreTestCase):
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class CertificateItemTest(ModuleStoreTestCase):
"""
Tests for paid certificate functionality (verified student), involves shoppingcart
"""
# test data
COURSE_SLUG = "100"
COURSE_NAME = "test_course"
COURSE_ORG = "EDX"
def setUp(self):
# Create course
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")
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
......@@ -2,6 +2,7 @@
Student Views
"""
import datetime
from datetime import date
import json
import logging
import random
......@@ -65,6 +66,7 @@ import external_auth.views
from bulk_email.models import Optout, CourseAuthorization
import shoppingcart
from shoppingcart.models import (Order, OrderItem, CertificateItem)
import track.views
......@@ -300,6 +302,7 @@ 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))
......@@ -335,15 +338,19 @@ def dashboard(request):
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 courses
if (has_access(request.user, course, 'refund') and (_enrollment.mode == "verified")))
# get info w.r.t ExternalAuthMap
external_auth_map = None
try:
external_auth_map = ExternalAuthMap.objects.get(user=user)
except ExternalAuthMap.DoesNotExist:
pass
context = {'courses': courses,
'course_optouts': course_optouts,
'message': message,
......@@ -356,6 +363,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)
......@@ -424,6 +432,8 @@ 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"))
......@@ -464,19 +474,42 @@ def change_enrollment(request):
elif action == "unenroll":
try:
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)]
)
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))
# email has been sent, let's deal with the order now
CertificateItem.refund_cert(user, course_id)
CourseEnrollment.unenroll(user, course_id)
return HttpResponse()
except CourseEnrollment.DoesNotExist:
return HttpResponseBadRequest(_("You are not enrolled in this course"))
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:
return HttpResponseBadRequest(_("Enrollment action is invalid"))
......@@ -891,7 +924,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'):
......
......@@ -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
from datetime import datetime, timedelta, date
from functools import partial
from django.conf import settings
......@@ -202,11 +202,33 @@ def _has_access_course_desc(user, course, action):
return can_enroll() or can_load()
def can_refund():
"""
For paid/verified certificates, students may receive a refund IFF the deadline
for refunds has not yet passed. Note that this function *only* checks whether
or not that deadline has passed; checking whether the student actually *purchased*
a paid/verified certificate must be done elsewhere.
"""
now = datetime.now(UTC())
course_start = course.enrollment_start
# If there *is* no start date, user can be refunded
if course_start is None:
return True
# Presently, refunds are only allowed up to two weeks after the course
# start date.
grace_period = timedelta(days=14)
refund_end = course_start + grace_period
if (now.date() <= refund_end.date()):
return True
return False
checkers = {
'load': can_load,
'load_forum': can_load_forum,
'enroll': can_enroll,
'see_exists': see_exists,
'refund': can_refund,
'staff': lambda: _has_staff_access_to_descriptor(user, course),
'instructor': lambda: _has_instructor_access_to_descriptor(user, course),
}
......
......@@ -106,3 +106,20 @@ class AccessTestCase(TestCase):
# TODO:
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed
def test__has_access_refund(self):
u = Mock()
today = datetime.datetime.now(UTC())
grace_period = datetime.timedelta(days=14)
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')
self.assertTrue(access._has_access_course_desc(u, c, 'refund'))
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')
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
from django.db import models
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import (ObjectDoesNotExist, MultipleObjectsReturned)
from django.core.mail import send_mail
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
......@@ -22,7 +22,6 @@ 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 verify_student.models import SoftwareSecurePhotoVerification
......@@ -34,13 +33,19 @@ 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
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):
"""
This is the model for an order. Before purchase, an Order and its related OrderItems are used
......@@ -399,6 +404,23 @@ class CertificateItem(OrderItem):
mode = models.SlugField()
@classmethod
def refund_cert(cls, target_user, target_course_id):
try:
target_cert = CertificateItem.objects.get(course_id=target_course_id, user_id=target_user, status='purchased', mode='verified')
target_cert.status = 'refunded'
# todo return success
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
log.exception("No certificate found")
# handle the exception
@classmethod
@transaction.commit_on_success
def add_to_order(cls, order, course_id, cost, mode, currency='usd'):
"""
......
......@@ -20,6 +20,7 @@ from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException
from django.core.exceptions import (ObjectDoesNotExist, MultipleObjectsReturned)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
......@@ -360,3 +361,27 @@ 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_single_cert(self):
# enroll and buy; dup from test_existing_enrollment
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()
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')
def test_refund_cert_no_cert_exists(self):
order = CertificateItem.refund_cert(target_user=self.user, target_course_id=self.course_id)
self.assertRaises(ObjectDoesNotExist)
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()
enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id)
self.assertRaises(MultipleObjectsReturned)
......@@ -20,6 +20,7 @@ USE_I18N = True
TEMPLATE_DEBUG = True
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
MITX_FEATURES['DISABLE_START_DATES'] = False
MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up
......@@ -269,7 +270,7 @@ if SEGMENT_IO_LMS_KEY:
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']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '')
CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '')
CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/'
########################## USER API ########################
......
......@@ -190,7 +190,8 @@
<% 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">
......
<%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 _ %>
<%!
......@@ -143,10 +143,41 @@
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}">${_('Unregister')}</a>
% 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>
<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>
% if enrollment.mode != "verified":
<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>
% 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/>
</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>
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