Added manual refund page for support.
PR-615
This commit is contained in:
@@ -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
|
||||
|
||||
124
lms/djangoapps/dashboard/support.py
Normal file
124
lms/djangoapps/dashboard/support.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
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/')
|
||||
13
lms/djangoapps/dashboard/support_urls.py
Normal file
13
lms/djangoapps/dashboard/support_urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
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"),
|
||||
)
|
||||
114
lms/djangoapps/dashboard/tests/test_support.py
Normal file
114
lms/djangoapps/dashboard/tests/test_support.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
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))
|
||||
73
lms/templates/dashboard/_dashboard_refund.html
Normal file
73
lms/templates/dashboard/_dashboard_refund.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% 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 %}
|
||||
10
lms/templates/dashboard/support.html
Normal file
10
lms/templates/dashboard/support.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% 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'):
|
||||
|
||||
Reference in New Issue
Block a user