diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 959d109029..256751eb6d 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -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
diff --git a/lms/djangoapps/dashboard/support.py b/lms/djangoapps/dashboard/support.py
new file mode 100644
index 0000000000..7ec4cb6628
--- /dev/null
+++ b/lms/djangoapps/dashboard/support.py
@@ -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/')
diff --git a/lms/djangoapps/dashboard/support_urls.py b/lms/djangoapps/dashboard/support_urls.py
new file mode 100644
index 0000000000..d16432b317
--- /dev/null
+++ b/lms/djangoapps/dashboard/support_urls.py
@@ -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"),
+)
diff --git a/lms/djangoapps/dashboard/tests/test_support.py b/lms/djangoapps/dashboard/tests/test_support.py
new file mode 100644
index 0000000000..24a2a78890
--- /dev/null
+++ b/lms/djangoapps/dashboard/tests/test_support.py
@@ -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))
diff --git a/lms/templates/dashboard/_dashboard_refund.html b/lms/templates/dashboard/_dashboard_refund.html
new file mode 100644
index 0000000000..34590a8268
--- /dev/null
+++ b/lms/templates/dashboard/_dashboard_refund.html
@@ -0,0 +1,73 @@
+{% extends "main_django.html" %}
+{% load i18n %}
+{% block title %}
+
+Manual Refund
+
+{% endblock %}
+{% block headextra %}
+
+
+{% endblock %}
+
+
+{% block body %}
+
+
+
{% trans "Manual Refund" %}
+ {% if messages %}
+
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% if cert %}
+
+
+ {% trans "About to refund this order:" %}
+
+
+ {% trans "Order Id:" %} {{cert.order.id}}
+
+
+ {% trans "Enrollment:" %} {{enrollment.course_id}} {{enrollment.mode}} ({% if enrollment.is_active %}{% trans "enrolled" %}{% else %}{% trans "unenrolled" %}{% endif %})
+
+
+ {% trans "Cost:" %} {{cert.unit_cost}} {{cert.currency}}
+
+
+ {% trans "CertificateItem Status:" %} {{cert.status}}
+
+
+ {% trans "Order Status:" %} {{cert.order.status}}
+
+
+ {% trans "Fulfilled Time:" %} {{cert.fulfilled_time}}
+
+
+ {% trans "Refund Request Time:" %} {{cert.refund_requested_time}}
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/lms/templates/dashboard/support.html b/lms/templates/dashboard/support.html
new file mode 100644
index 0000000000..dd324d3dc2
--- /dev/null
+++ b/lms/templates/dashboard/support.html
@@ -0,0 +1,10 @@
+{% extends "main_django.html" %}
+{% load i18n %}
+{% block title %}Support Dashboard{% endblock %}
+
+{% block body %}
+
+
+{% endblock %}
diff --git a/lms/urls.py b/lms/urls.py
index cb4af372c7..86ef19a076 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -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[^/]+/[^/]+/[^/]+)/(?P[^/]+)/$',
- 'courseware.views.static_tab', name="static_tab"),
+ 'courseware.views.static_tab', name="static_tab"),
)
if settings.FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'):