Commit f2fb57e1 by Dave St.Germain

Added manual refund page for support.

PR-615
parent b60b7c7e
......@@ -943,9 +943,16 @@ class CourseEnrollment(models.Model):
def refundable(self):
"""
For paid/verified certificates, students may receive a refund IFF they have
For paid/verified certificates, students may receive a refund if they have
a verified certificate and the deadline for refunds has not yet passed.
"""
# In order to support manual refunds past the deadline, set can_refund on this object.
# On unenrolling, the "unenroll_done" signal calls CertificateItem.refund_cert_callback(),
# which calls this method to determine whether to refund the order.
# This can't be set directly because refunds currently happen as a side-effect of unenrolling.
# (side-effects are bad)
if getattr(self, 'can_refund', None) is not None:
return True
course_mode = CourseMode.mode_for_course(self.course_id, 'verified')
if course_mode is None:
return False
......
"""
Views for support dashboard
"""
import logging
from django.contrib.auth.models import User
from django.views.generic.edit import FormView
from django.views.generic.base import TemplateView
from django.utils.translation import ugettext as _
from django.http import HttpResponseRedirect
from django.contrib import messages
from django import forms
from student.models import CourseEnrollment
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
log = logging.getLogger(__name__)
class RefundForm(forms.Form): # pylint: disable=R0924
"""
Form for manual refunds
"""
user = forms.EmailField(label=_("Email Address"), required=True)
course_id = forms.CharField(label=_("Course ID"), required=True)
confirmed = forms.CharField(widget=forms.HiddenInput, required=False)
def clean_user(self):
"""
validate user field
"""
user_email = self.cleaned_data['user']
try:
user = User.objects.get(email=user_email)
except User.DoesNotExist:
raise forms.ValidationError(_("User not found"))
return user
def clean_course_id(self):
"""
validate course id field
"""
course_id = self.cleaned_data['course_id']
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
try:
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
except InvalidKeyError:
raise forms.ValidationError(_("Invalid course id"))
return course_key
def clean(self):
"""
clean form
"""
user, course_id = self.cleaned_data.get('user'), self.cleaned_data.get('course_id')
if user and course_id:
self.cleaned_data['enrollment'] = enrollment = CourseEnrollment.get_or_create_enrollment(user, course_id)
if enrollment.refundable():
raise forms.ValidationError(_("Course {course_id} not past the refund window.").format(course_id=course_id))
try:
self.cleaned_data['cert'] = enrollment.certificateitem_set.filter(mode='verified', status='purchased')[0]
except IndexError:
raise forms.ValidationError(_("No order found for {user} in course {course_id}").format(user=user, course_id=course_id))
return self.cleaned_data
def is_valid(self):
"""
returns whether form is valid
"""
is_valid = super(RefundForm, self).is_valid()
if is_valid and self.cleaned_data.get('confirmed') != 'true':
# this is a two-step form: first look up the data, then issue the refund.
# first time through, set the hidden "confirmed" field to true and then redisplay the form
# second time through, do the unenrollment/refund.
data = dict(self.data.items())
self.cleaned_data['confirmed'] = data['confirmed'] = 'true'
self.data = data
is_valid = False
return is_valid
class SupportDash(TemplateView):
"""
Support dashboard view
"""
template_name = 'dashboard/support.html'
class Refund(FormView):
"""
Refund form view
"""
template_name = 'dashboard/_dashboard_refund.html'
form_class = RefundForm
success_url = '/support/'
def get_context_data(self, **kwargs):
"""
extra context data to add to page
"""
form = getattr(kwargs['form'], 'cleaned_data', {})
if form.get('confirmed') == 'true':
kwargs['cert'] = form.get('cert')
kwargs['enrollment'] = form.get('enrollment')
return kwargs
def form_valid(self, form):
"""
unenrolls student, issues refund
"""
user = form.cleaned_data['user']
course_id = form.cleaned_data['course_id']
enrollment = form.cleaned_data['enrollment']
cert = form.cleaned_data['cert']
enrollment.can_refund = True
enrollment.update_enrollment(is_active=False)
log.info(u"%s manually refunded %s %s", self.request.user, user, course_id)
messages.success(self.request, _("Unenrolled {user} from {course_id}").format(user=user, course_id=course_id))
messages.success(self.request, _("Refunded {cost} for order id {order_id}").format(cost=cert.unit_cost, order_id=cert.order.id))
return HttpResponseRedirect('/support/refund/')
"""
URLs for support dashboard
"""
from django.conf.urls import patterns, url
from django.contrib.auth.decorators import permission_required
from dashboard import support
urlpatterns = patterns(
'',
url(r'^$', permission_required('student.change_courseenrollment')(support.SupportDash.as_view()), name="support_dashboard"),
url(r'^refund/?$', permission_required('student.change_courseenrollment')(support.Refund.as_view()), name="support_refund"),
)
"""
Tests for support dashboard
"""
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from django.test.client import Client
from django.test.utils import override_settings
from django.contrib.auth.models import Permission
from shoppingcart.models import CertificateItem, Order
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
import datetime
@override_settings(
MODULESTORE=TEST_DATA_MONGO_MODULESTORE
)
class RefundTests(ModuleStoreTestCase):
"""
Tests for the manual refund page
"""
def setUp(self):
self.course = CourseFactory.create(
org='testorg', number='run1', display_name='refundable course'
)
self.course_id = self.course.location.course_key
self.client = Client()
self.admin = UserFactory.create(
username='test_admin',
email='test_admin+support@edx.org',
password='foo'
)
self.admin.user_permissions.add(Permission.objects.get(codename='change_courseenrollment'))
self.client.login(username=self.admin.username, password='foo')
self.student = UserFactory.create(
username='student',
email='student+refund@edx.org'
)
self.course_mode = CourseMode.objects.get_or_create(course_id=self.course_id, mode_slug='verified')[0]
self.order = None
self.form_pars = {'course_id': str(self.course_id), 'user': self.student.email}
def tearDown(self):
self.course_mode.delete()
Order.objects.filter(user=self.student).delete()
def _enroll(self, purchase=True):
# pylint: disable=C0111
CourseEnrollment.enroll(self.student, self.course_id, self.course_mode.mode_slug)
if purchase:
self.order = Order.get_cart_for_user(self.student)
CertificateItem.add_to_order(self.order, self.course_id, 1, self.course_mode.mode_slug)
self.order.purchase()
self.course_mode.expiration_datetime = datetime.datetime(1983, 4, 6)
self.course_mode.save()
def test_support_access(self):
response = self.client.get('/support/')
self.assertTrue(response.status_code, 200)
self.assertContains(response, 'Manual Refund')
response = self.client.get('/support/refund/')
self.assertTrue(response.status_code, 200)
# users without the permission can't access support
self.admin.user_permissions.clear()
response = self.client.get('/support/')
self.assertTrue(response.status_code, 302)
response = self.client.get('/support/refund/')
self.assertTrue(response.status_code, 302)
def test_bad_courseid(self):
response = self.client.post('/support/refund/', {'course_id': 'foo', 'user': self.student.email})
self.assertContains(response, 'Invalid course id')
def test_bad_user(self):
response = self.client.post('/support/refund/', {'course_id': str(self.course_id), 'user': 'unknown@foo.com'})
self.assertContains(response, 'User not found')
def test_not_refundable(self):
self._enroll()
self.course_mode.expiration_datetime = datetime.datetime(2033, 4, 6)
self.course_mode.save()
response = self.client.post('/support/refund/', self.form_pars)
self.assertContains(response, 'not past the refund window')
def test_no_order(self):
self._enroll(purchase=False)
response = self.client.post('/support/refund/', self.form_pars)
self.assertContains(response, 'No order found for %s' % self.student.username)
def test_valid_order(self):
self._enroll()
response = self.client.post('/support/refund/', self.form_pars)
self.assertContains(response, "About to refund this order")
self.assertContains(response, "enrolled")
self.assertContains(response, "CertificateItem Status")
def test_do_refund(self):
self._enroll()
pars = self.form_pars
pars['confirmed'] = 'true'
response = self.client.post('/support/refund/', pars)
self.assertTrue(response.status_code, 302)
response = self.client.get(response.get('location')) # pylint: disable=E1103
self.assertContains(response, "Unenrolled %s from" % self.student)
self.assertContains(response, "Refunded 1 for order id")
self.assertFalse(CourseEnrollment.is_enrolled(self.student, self.course_id))
{% extends "main_django.html" %}
{% load i18n %}
{% block title %}
<title>
Manual Refund
</title>
{% endblock %}
{% block headextra %}
<style type="text/css">
.errorlist,.messages {
color: red;
}
.success {
color: green;
}
strong {
padding-right: 10px;
}
</style>
{% endblock %}
{% block body %}
<div class="content-wrapper" id="content">
<div class="container about">
<h1>{% trans "Manual Refund" %}</h1>
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<form method="POST" id="refund_form">
{% csrf_token %}
{{form.as_p}}
<p>
<input type="button" value="Cancel" onclick="javascript:location=location"/> <input type="submit" value="{% if cert %}Refund{% else %}Confirm{% endif %}" />
</p>
</form>
{% if cert %}
<section class="content-wrapper">
<h2>
{% trans "About to refund this order:" %}
</h2>
<p>
<strong>{% trans "Order Id:" %}</strong> {{cert.order.id}}
</p>
<p>
<strong>{% trans "Enrollment:" %}</strong> {{enrollment.course_id}} {{enrollment.mode}} ({% if enrollment.is_active %}{% trans "enrolled" %}{% else %}{% trans "unenrolled" %}{% endif %})
</p>
<p>
<strong>{% trans "Cost:" %}</strong> {{cert.unit_cost}} {{cert.currency}}
</p>
<p>
<strong>{% trans "CertificateItem Status:" %}</strong> {{cert.status}}
</p>
<p>
<strong>{% trans "Order Status:" %}</strong> {{cert.order.status}}
</p>
<p>
<strong>{% trans "Fulfilled Time:" %}</strong> {{cert.fulfilled_time}}
</p>
<p>
<strong>{% trans "Refund Request Time:" %}</strong> {{cert.refund_requested_time}}
</p>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% extends "main_django.html" %}
{% load i18n %}
{% block title %}<title>Support Dashboard</title>{% endblock %}
{% block body %}
<ul>
<li><a href="/support/refund/">{% trans "Manual Refund" %}</a></li>
</ul>
{% endblock %}
......@@ -67,7 +67,7 @@ urlpatterns = ('', # nopep8
url(r'^i18n/', include('django.conf.urls.i18n')),
url(r'^embargo$', 'student.views.embargo', name="embargo"),
# Feedback Form endpoint
url(r'^submit_feedback$', 'util.views.submit_feedback'),
)
......@@ -96,6 +96,10 @@ if settings.FEATURES["ENABLE_SYSADMIN_DASHBOARD"]:
url(r'^sysadmin/', include('dashboard.sysadmin_urls')),
)
urlpatterns += (
url(r'support/', include('dashboard.support_urls')),
)
#Semi-static views (these need to be rendered and have the login bar, but don't change)
urlpatterns += (
url(r'^404$', 'static_template_view.views.render',
......@@ -352,7 +356,7 @@ if settings.COURSEWARE_ENABLED:
urlpatterns += (
# This MUST be the last view in the courseware--it's a catch-all for custom tabs.
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/(?P<tab_slug>[^/]+)/$',
'courseware.views.static_tab', name="static_tab"),
'courseware.views.static_tab', name="static_tab"),
)
if settings.FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'):
......
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