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 %} + + {% endif %} + +
+ {% csrf_token %} + {{form.as_p}} +

+ +

+
+ {% 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'):