Commit 5a54bcb8 by Albert St. Aubin

Refactor of the CourseEntitlement Refund API to handle refund failures

[LEARNER-3629]

The CourseEntitlement Refund API will not respond with ERROR codes when
the attempted refund call to Ecommerce fails.
parent d48a1d5f
......@@ -22,7 +22,6 @@ if settings.ROOT_URLCONF == 'lms.urls':
from entitlements.tests.factories import CourseEntitlementFactory
from entitlements.models import CourseEntitlement
from entitlements.api.v1.serializers import CourseEntitlementSerializer
from entitlements.signals import REFUND_ENTITLEMENT
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
......@@ -272,6 +271,8 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
def setUp(self):
super(EntitlementEnrollmentViewSetTest, self).setUp()
self.user = UserFactory()
UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True)
self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course')
self.course2 = CourseFactory.create(org='edX', number='DemoX2', display_name='Demo_Course 2')
......@@ -431,8 +432,8 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.data['message'] == expected_message # pylint: disable=no-member
assert not CourseEnrollment.is_enrolled(self.user, fake_course_key)
@patch('lms.djangoapps.commerce.signals.refund_entitlement', return_value=[1])
@patch("entitlements.api.v1.views.get_course_runs_for_course")
@patch('entitlements.api.v1.views.refund_entitlement', return_value=True)
@patch('entitlements.api.v1.views.get_course_runs_for_course')
def test_user_can_revoke_and_refund(self, mock_get_course_runs, mock_refund_entitlement):
course_entitlement = CourseEntitlementFactory.create(user=self.user)
mock_get_course_runs.return_value = self.return_values
......@@ -457,10 +458,6 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# Unenroll with Revoke for refund
with patch('lms.djangoapps.commerce.signals.handle_refund_entitlement') as mock_refund_handler:
REFUND_ENTITLEMENT.connect(mock_refund_handler)
# pre_db_changes_entitlement = course_entitlement
revoke_url = url + '?is_refund=true'
response = self.client.delete(
revoke_url,
......@@ -469,16 +466,16 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.status_code == 204
course_entitlement.refresh_from_db()
assert mock_refund_handler.called
assert (CourseEntitlementSerializer(mock_refund_handler.call_args[1]['course_entitlement']).data ==
assert mock_refund_entitlement.is_called
assert (CourseEntitlementSerializer(mock_refund_entitlement.call_args[1]['course_entitlement']).data ==
CourseEntitlementSerializer(course_entitlement).data)
assert not CourseEnrollment.is_enrolled(self.user, self.course.id)
assert course_entitlement.enrollment_course_run is None
assert course_entitlement.expired_at is not None
@patch('entitlements.api.v1.views.CourseEntitlement.is_entitlement_refundable', return_value=False)
@patch('lms.djangoapps.commerce.signals.refund_entitlement', return_value=[1])
@patch("entitlements.api.v1.views.get_course_runs_for_course")
@patch('entitlements.api.v1.views.refund_entitlement', return_value=True)
@patch('entitlements.api.v1.views.get_course_runs_for_course')
def test_user_can_revoke_and_no_refund_available(
self,
mock_get_course_runs,
......@@ -508,9 +505,6 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# Unenroll with Revoke for refund
with patch('lms.djangoapps.commerce.signals.handle_refund_entitlement') as mock_refund_handler:
REFUND_ENTITLEMENT.connect(mock_refund_handler)
revoke_url = url + '?is_refund=true'
response = self.client.delete(
revoke_url,
......@@ -519,7 +513,51 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.status_code == 400
course_entitlement.refresh_from_db()
assert not mock_refund_handler.called
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
assert course_entitlement.enrollment_course_run is not None
assert course_entitlement.expired_at is None
@patch('entitlements.api.v1.views.CourseEntitlement.is_entitlement_refundable', return_value=True)
@patch('entitlements.api.v1.views.refund_entitlement', return_value=False)
@patch("entitlements.api.v1.views.get_course_runs_for_course")
def test_user_is_not_unenrolled_on_failed_refund(
self,
mock_get_course_runs,
mock_refund_entitlement,
mock_is_refundable
):
course_entitlement = CourseEntitlementFactory.create(user=self.user)
mock_get_course_runs.return_value = self.return_values
url = reverse(
self.ENTITLEMENTS_ENROLLMENT_NAMESPACE,
args=[str(course_entitlement.uuid)]
)
assert course_entitlement.enrollment_course_run is None
# Enroll the User
data = {
'course_run_id': str(self.course.id)
}
response = self.client.post(
url,
data=json.dumps(data),
content_type='application/json',
)
course_entitlement.refresh_from_db()
assert response.status_code == 201
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# Unenroll with Revoke for refund
revoke_url = url + '?is_refund=true'
response = self.client.delete(
revoke_url,
content_type='application/json',
)
assert response.status_code == 409
course_entitlement.refresh_from_db()
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
assert course_entitlement.enrollment_course_run is not None
assert course_entitlement.expired_at is None
......@@ -14,7 +14,7 @@ from entitlements.api.v1.filters import CourseEntitlementFilter
from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly
from entitlements.api.v1.serializers import CourseEntitlementSerializer
from entitlements.models import CourseEntitlement
from entitlements.signals import REFUND_ENTITLEMENT
from lms.djangoapps.commerce.utils import refund_entitlement
from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from student.models import CourseEnrollment
......@@ -274,22 +274,15 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
)
if is_refund and entitlement.is_entitlement_refundable():
with transaction.atomic():
# Revoke and refund the entitlement
if entitlement.enrollment_course_run is not None:
self._unenroll_entitlement(
entitlement=entitlement,
course_run_key=entitlement.enrollment_course_run.course_id,
user=request.user
)
# Revoke the Course Entitlement and issue Refund
log.info(
'Entitlement Refund requested for Course Entitlement[%s]',
str(entitlement.uuid)
)
refund_status = refund_entitlement(course_entitlement=entitlement)
REFUND_ENTITLEMENT.send(sender=None, course_entitlement=entitlement)
if refund_status:
with transaction.atomic():
entitlement.expired_at_datetime = timezone.now()
entitlement.save()
......@@ -298,6 +291,26 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
entitlement.expired_at,
entitlement.uuid
)
# Revoke and refund the entitlement
if entitlement.enrollment_course_run is not None:
self._unenroll_entitlement(
entitlement=entitlement,
course_run_key=entitlement.enrollment_course_run.course_id,
user=request.user
)
else:
# This state is achieved in most cases by a failure in the ecommerce service to process the refund.
log.info(
'Entitlement Refund failed for Course Entitlement [%s], alert User',
str(entitlement.uuid)
)
return Response(
status=status.HTTP_409_CONFLICT,
data={
'message': 'Entitlement refund failed due to refund process failure or conflict'
})
elif not is_refund:
if entitlement.enrollment_course_run is not None:
self._unenroll_entitlement(
......
......@@ -3,24 +3,16 @@ Signal handling functions for use with external commerce service.
"""
from __future__ import unicode_literals
import json
import logging
from urlparse import urljoin
import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.dispatch import receiver
from django.utils.translation import ugettext as _
from entitlements.signals import REFUND_ENTITLEMENT
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
from openedx.core.djangoapps.commerce.utils import is_commerce_service_configured
from request_cache.middleware import RequestCache
from student.signals import REFUND_ORDER
from .models import CommerceConfiguration
from .utils import refund_entitlement, refund_seat
log = logging.getLogger(__name__)
......@@ -91,220 +83,3 @@ def get_request_user():
"""
request = RequestCache.get_current_request()
return getattr(request, 'user', None)
def _process_refund(refund_ids, api_client, course_product, is_entitlement=False):
"""
Helper method to process a refund for a given course_product
"""
config = CommerceConfiguration.current()
if config.enable_automatic_refund_approval:
refunds_requiring_approval = []
for refund_id in refund_ids:
try:
# NOTE: Approve payment only because the user has already been unenrolled. Additionally, this
# ensures we don't tie up an additional web worker when the E-Commerce Service tries to unenroll
# the learner
api_client.refunds(refund_id).process.put({'action': 'approve_payment_only'})
log.info('Refund [%d] successfully approved.', refund_id)
except: # pylint: disable=bare-except
log.exception('Failed to automatically approve refund [%d]!', refund_id)
refunds_requiring_approval.append(refund_id)
else:
refunds_requiring_approval = refund_ids
if refunds_requiring_approval:
# XCOM-371: this is a temporary measure to suppress refund-related email
# notifications to students and support for free enrollments. This
# condition should be removed when the CourseEnrollment.refundable() logic
# is updated to be more correct, or when we implement better handling (and
# notifications) in Otto for handling reversal of $0 transactions.
if course_product.mode != 'verified':
# 'verified' is the only enrollment mode that should presently
# result in opening a refund request.
msg = 'Skipping refund email notification for non-verified mode for user [%s], course [%s], mode: [%s]'
course_identifier = course_product.course_id
if is_entitlement:
course_identifier = str(course_product.uuid)
msg = ('Skipping refund email notification for non-verified mode for user [%s], '
'course entitlement [%s], mode: [%s]')
log.info(
msg,
course_product.user.id,
course_identifier,
course_product.mode,
)
else:
try:
send_refund_notification(course_product, refunds_requiring_approval)
except: # pylint: disable=bare-except
# don't break, just log a warning
log.warning('Could not send email notification for refund.', exc_info=True)
def refund_seat(course_enrollment):
"""
Attempt to initiate a refund for any orders associated with the seat being unenrolled, using the commerce service.
Arguments:
course_enrollment (CourseEnrollment): a student enrollment
Returns:
A list of the external service's IDs for any refunds that were initiated
(may be empty).
Raises:
exceptions.SlumberBaseException: for any unhandled HTTP error during communication with the E-Commerce Service.
exceptions.Timeout: if the attempt to reach the commerce service timed out.
"""
User = get_user_model() # pylint:disable=invalid-name
course_key_str = unicode(course_enrollment.course_id)
enrollee = course_enrollment.user
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
log.info('Attempting to create a refund for user [%s], course [%s]...', enrollee.id, course_key_str)
refund_ids = api_client.refunds.post({'course_id': course_key_str, 'username': enrollee.username})
if refund_ids:
log.info('Refund successfully opened for user [%s], course [%s]: %r', enrollee.id, course_key_str, refund_ids)
_process_refund(
refund_ids=refund_ids,
api_client=api_client,
course_product=course_enrollment,
)
else:
log.info('No refund opened for user [%s], course [%s]', enrollee.id, course_key_str)
return refund_ids
def refund_entitlement(course_entitlement):
"""
Attempt a refund of a course entitlement
:param course_entitlement:
:return:
"""
user_model = get_user_model()
enrollee = course_entitlement.user
entitlement_uuid = str(course_entitlement.uuid)
service_user = user_model.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
log.info(
'Attempting to create a refund for user [%s], course entitlement [%s]...',
enrollee.username,
entitlement_uuid
)
refund_ids = api_client.refunds.post(
{
'order_number': course_entitlement.order_number,
'username': enrollee.username,
'entitlement_uuid': entitlement_uuid,
}
)
if refund_ids:
log.info(
'Refund successfully opened for user [%s], course entitlement [%s]: %r',
enrollee.username,
entitlement_uuid,
refund_ids,
)
_process_refund(
refund_ids=refund_ids,
api_client=api_client,
course_product=course_entitlement,
is_entitlement=True
)
else:
log.info('No refund opened for user [%s], course entitlement [%s]', enrollee.id, entitlement_uuid)
return refund_ids
def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=None):
""" Create a Zendesk ticket via API. """
if not (settings.ZENDESK_URL and settings.ZENDESK_USER and settings.ZENDESK_API_KEY):
log.debug('Zendesk is not configured. Cannot create a ticket.')
return
# Copy the tags to avoid modifying the original list.
tags = list(tags or [])
tags.append('LMS')
# Remove duplicates
tags = list(set(tags))
data = {
'ticket': {
'requester': {
'name': requester_name,
'email': requester_email
},
'subject': subject,
'comment': {'body': body},
'tags': tags
}
}
# Encode the data to create a JSON payload
payload = json.dumps(data)
# Set the request parameters
url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json')
user = '{}/token'.format(settings.ZENDESK_USER)
pwd = settings.ZENDESK_API_KEY
headers = {'content-type': 'application/json'}
try:
response = requests.post(url, data=payload, auth=(user, pwd), headers=headers)
# Check for HTTP codes other than 201 (Created)
if response.status_code != 201:
log.error('Failed to create ticket. Status: [%d], Body: [%s]', response.status_code, response.content)
else:
log.debug('Successfully created ticket.')
except Exception: # pylint: disable=broad-except
log.exception('Failed to create ticket.')
return
def generate_refund_notification_body(student, refund_ids): # pylint: disable=invalid-name
""" Returns a refund notification message body. """
msg = _(
"A refund request has been initiated for {username} ({email}). "
"To process this request, please visit the link(s) below."
).format(username=student.username, email=student.email)
ecommerce_url_root = configuration_helpers.get_value(
'ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT,
)
refund_urls = [urljoin(ecommerce_url_root, '/dashboard/refunds/{}/'.format(refund_id))
for refund_id in refund_ids]
return '{msg}\n\n{urls}'.format(msg=msg, urls='\n'.join(refund_urls))
def send_refund_notification(course_product, refund_ids):
""" Notify the support team of the refund request. """
tags = ['auto_refund']
if theming_helpers.is_request_in_themed_site():
# this is not presently supported with the external service.
raise NotImplementedError("Unable to send refund processing emails to support teams.")
student = course_product.user
subject = _("[Refund] User-Requested Refund")
body = generate_refund_notification_body(student, refund_ids)
requester_name = student.profile.name or student.username
create_zendesk_ticket(requester_name, student.email, subject, body, tags)
......@@ -26,7 +26,7 @@ from student.tests.factories import CourseEnrollmentFactory, UserFactory
from . import JSON
from .mocks import mock_create_refund, mock_process_refund
from ..models import CommerceConfiguration
from ..signals import create_zendesk_ticket, generate_refund_notification_body, send_refund_notification
from ..utils import create_zendesk_ticket, _generate_refund_notification_body, _send_refund_notification
ZENDESK_URL = 'http://zendesk.example.com/'
ZENDESK_USER = 'test@example.com'
......@@ -143,7 +143,7 @@ class TestRefundSignal(TestCase):
self.send_signal()
self.assertTrue(mock_log_exception.called)
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
def test_notification_when_approval_fails(self, mock_send_notification):
"""
Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
......@@ -158,7 +158,7 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_send_notification.called)
mock_send_notification.assert_called_with(self.course_enrollment, [failed_refund_id])
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
def test_notification_if_automatic_approval_disabled(self, mock_send_notification):
"""
Ensure the notification is always sent if the automatic approval functionality is disabled.
......@@ -172,7 +172,7 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_send_notification.called)
mock_send_notification.assert_called_with(self.course_enrollment, [refund_id])
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
def test_no_notification_after_approval(self, mock_send_notification):
"""
Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
......@@ -187,7 +187,7 @@ class TestRefundSignal(TestCase):
last_request = httpretty.last_request()
self.assertDictEqual(json.loads(last_request.body), {'action': 'approve_payment_only'})
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
def test_notification_no_refund(self, mock_send_notification):
"""
Ensure the notification function is NOT triggered when no refunds are
......@@ -197,7 +197,7 @@ class TestRefundSignal(TestCase):
self.send_signal()
self.assertFalse(mock_send_notification.called)
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
@ddt.data(
CourseMode.HONOR,
CourseMode.PROFESSIONAL,
......@@ -218,8 +218,8 @@ class TestRefundSignal(TestCase):
self.send_signal()
self.assertFalse(mock_send_notification.called)
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification', side_effect=Exception("Splat!"))
@mock.patch('lms.djangoapps.commerce.signals.log.warning')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification', side_effect=Exception("Splat!"))
@mock.patch('lms.djangoapps.commerce.utils.log.warning')
def test_notification_error(self, mock_log_warning, mock_send_notification):
"""
Ensure an error occuring during notification does not break program
......@@ -237,10 +237,10 @@ class TestRefundSignal(TestCase):
context of themed site.
"""
with self.assertRaises(NotImplementedError):
send_refund_notification(self.course_enrollment, [1, 2, 3])
_send_refund_notification(self.course_enrollment, [1, 2, 3])
@ddt.data('email@example.com', 'üñîcode.email@example.com')
@mock.patch('lms.djangoapps.commerce.signals.create_zendesk_ticket')
@mock.patch('lms.djangoapps.commerce.utils.create_zendesk_ticket')
def test_send_refund_notification(self, student_email, mock_zendesk):
""" Verify the support team is notified of the refund request. """
refund_ids = [1, 2, 3]
......@@ -249,8 +249,8 @@ class TestRefundSignal(TestCase):
# generate_refund_notification_body can handle formatting a unicode
# message
self.student.email = student_email
send_refund_notification(self.course_enrollment, refund_ids)
body = generate_refund_notification_body(self.student, refund_ids)
_send_refund_notification(self.course_enrollment, refund_ids)
body = _generate_refund_notification_body(self.student, refund_ids)
mock_zendesk.assert_called_with(
self.student.profile.name,
self.student.email,
......@@ -417,7 +417,7 @@ class TestRevokeEntitlementSignal(TestCase):
self.assertFalse(mock_refund_entitlement.called)
@mock.patch('lms.djangoapps.commerce.signals.get_request_user',)
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
def test_notification_when_approval_fails(self, mock_send_notification, mock_get_user):
"""
Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
......@@ -434,7 +434,7 @@ class TestRevokeEntitlementSignal(TestCase):
mock_send_notification.assert_called_with(self.course_entitlement, [failed_refund_id])
@mock.patch('lms.djangoapps.commerce.signals.get_request_user')
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification')
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
def test_notification_if_automatic_approval_disabled(self, mock_send_notification, mock_get_user):
"""
Ensure the notification is always sent if the automatic approval functionality is disabled.
......@@ -456,4 +456,4 @@ class TestRevokeEntitlementSignal(TestCase):
context of themed site.
"""
with self.assertRaises(NotImplementedError):
send_refund_notification(self.course_entitlement, [1, 2, 3])
_send_refund_notification(self.course_entitlement, [1, 2, 3])
"""Utilities to assist with commerce tasks."""
import json
import logging
from urllib import urlencode
from urlparse import urljoin
import requests
import waffle
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
from student.models import CourseEnrollment
from .models import CommerceConfiguration
log = logging.getLogger(__name__)
def is_account_activation_requirement_disabled():
"""
......@@ -109,3 +117,248 @@ class EcommerceService(object):
else:
return reverse('verify_student_upgrade_and_verify', args=(course_key,))
return None
def refund_entitlement(course_entitlement):
"""
Attempt a refund of a course entitlement. Verify the User before calling this refund method
Returns:
bool: True if the Refund is successfully processed.
"""
user_model = get_user_model()
enrollee = course_entitlement.user
entitlement_uuid = str(course_entitlement.uuid)
if not is_commerce_service_configured():
log.error(
'Ecommerce service is not configured, cannot refund for user [%s], course entitlement [%s].',
enrollee.username,
entitlement_uuid
)
return False
service_user = user_model.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
log.info(
'Attempting to create a refund for user [%s], course entitlement [%s]...',
enrollee.username,
entitlement_uuid
)
try:
refund_ids = api_client.refunds.post(
{
'order_number': course_entitlement.order_number,
'username': enrollee.username,
'entitlement_uuid': entitlement_uuid,
}
)
except Exception as exc: # pylint: disable=broad-except
# Catch any possible exceptions from the Ecommerce service to ensure we fail gracefully
log.exception(
"Unexpected exception while attempting to initiate refund for user [%s], "
"course entitlement [%s] message: [%s]",
course_entitlement.user.id,
course_entitlement.uuid,
str(exc)
)
return False
if refund_ids:
log.info(
'Refund successfully opened for user [%s], course entitlement [%s]: %r',
enrollee.username,
entitlement_uuid,
refund_ids,
)
_process_refund(
refund_ids=refund_ids,
api_client=api_client,
course_product=course_entitlement,
is_entitlement=True
)
else:
log.info('No refund opened for user [%s], course entitlement [%s]', enrollee.id, entitlement_uuid)
return True
def refund_seat(course_enrollment):
"""
Attempt to initiate a refund for any orders associated with the seat being unenrolled,
using the commerce service.
Arguments:
course_enrollment (CourseEnrollment): a student enrollment
Returns:
A list of the external service's IDs for any refunds that were initiated
(may be empty).
Raises:
exceptions.SlumberBaseException: for any unhandled HTTP error during communication with the E-Commerce Service.
exceptions.Timeout: if the attempt to reach the commerce service timed out.
"""
User = get_user_model() # pylint:disable=invalid-name
course_key_str = unicode(course_enrollment.course_id)
enrollee = course_enrollment.user
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
log.info('Attempting to create a refund for user [%s], course [%s]...', enrollee.id, course_key_str)
refund_ids = api_client.refunds.post({'course_id': course_key_str, 'username': enrollee.username})
if refund_ids:
log.info('Refund successfully opened for user [%s], course [%s]: %r', enrollee.id, course_key_str, refund_ids)
_process_refund(
refund_ids=refund_ids,
api_client=api_client,
course_product=course_enrollment,
)
else:
log.info('No refund opened for user [%s], course [%s]', enrollee.id, course_key_str)
return refund_ids
def _process_refund(refund_ids, api_client, course_product, is_entitlement=False):
"""
Helper method to process a refund for a given course_product
Returns:
bool: True if the refund process was successful, False if there are any Errors that are not handled
"""
config = CommerceConfiguration.current()
if config.enable_automatic_refund_approval:
refunds_requiring_approval = []
for refund_id in refund_ids:
try:
# NOTE: Approve payment only because the user has already been unenrolled. Additionally, this
# ensures we don't tie up an additional web worker when the E-Commerce Service tries to unenroll
# the learner
api_client.refunds(refund_id).process.put({'action': 'approve_payment_only'})
log.info('Refund [%d] successfully approved.', refund_id)
except: # pylint: disable=bare-except
# Push the refund to Support to process
log.exception('Failed to automatically approve refund [%d]!', refund_id)
refunds_requiring_approval.append(refund_id)
else:
refunds_requiring_approval = refund_ids
if refunds_requiring_approval:
# XCOM-371: this is a temporary measure to suppress refund-related email
# notifications to students and support for free enrollments. This
# condition should be removed when the CourseEnrollment.refundable() logic
# is updated to be more correct, or when we implement better handling (and
# notifications) in Otto for handling reversal of $0 transactions.
if course_product.mode != 'verified':
# 'verified' is the only enrollment mode that should presently
# result in opening a refund request.
msg = 'Skipping refund support notification for non-verified mode for user [%s], course [%s], mode: [%s]'
course_identifier = course_product.course_id
if is_entitlement:
course_identifier = str(course_product.uuid)
msg = ('Skipping refund support notification for non-verified mode for user [%s], '
'course entitlement [%s], mode: [%s]')
log.info(
msg,
course_product.user.id,
course_identifier,
course_product.mode,
)
return False
else:
try:
_send_refund_notification(course_product, refunds_requiring_approval)
except: # pylint: disable=bare-except
# Unable to send notification to Support, do not break as this method is used by Signals
log.warning('Could not send support notification for refund.', exc_info=True)
return False
return True
def _send_refund_notification(course_product, refund_ids):
""" Notify the support team of the refund request. """
tags = ['auto_refund']
if theming_helpers.is_request_in_themed_site():
# this is not presently supported with the external service.
raise NotImplementedError("Unable to send refund processing emails to support teams.")
student = course_product.user
subject = _("[Refund] User-Requested Refund")
body = _generate_refund_notification_body(student, refund_ids)
requester_name = student.profile.name or student.username
create_zendesk_ticket(requester_name, student.email, subject, body, tags)
def _generate_refund_notification_body(student, refund_ids): # pylint: disable=invalid-name
""" Returns a refund notification message body. """
msg = _(
'A refund request has been initiated for {username} ({email}). '
'To process this request, please visit the link(s) below.'
).format(username=student.username, email=student.email)
ecommerce_url_root = configuration_helpers.get_value(
'ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT,
)
refund_urls = [urljoin(ecommerce_url_root, '/dashboard/refunds/{}/'.format(refund_id))
for refund_id in refund_ids]
# emails contained in this message could contain unicode characters so encode as such
return u'{msg}\n\n{urls}'.format(msg=msg, urls='\n'.join(refund_urls))
def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=None):
""" Create a Zendesk ticket via API. """
if not (settings.ZENDESK_URL and settings.ZENDESK_USER and settings.ZENDESK_API_KEY):
log.debug('Zendesk is not configured. Cannot create a ticket.')
return
# Copy the tags to avoid modifying the original list.
tags = list(tags or [])
tags.append('LMS')
# Remove duplicates
tags = list(set(tags))
data = {
'ticket': {
'requester': {
'name': requester_name,
'email': unicode(requester_email)
},
'subject': subject,
'comment': {'body': body},
'tags': tags
}
}
# Encode the data to create a JSON payload
payload = json.dumps(data)
# Set the request parameters
url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json')
user = '{}/token'.format(settings.ZENDESK_USER)
pwd = settings.ZENDESK_API_KEY
headers = {'content-type': 'application/json'}
try:
response = requests.post(url, data=payload, auth=(user, pwd), headers=headers)
# Check for HTTP codes other than 201 (Created)
if response.status_code != 201:
log.error('Failed to create ticket. Status: [%d], Body: [%s]', response.status_code, response.content)
else:
log.debug('Successfully created ticket.')
except Exception: # pylint: disable=broad-except
log.exception('Failed to create ticket.')
return
......@@ -11,7 +11,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
import lms.djangoapps.instructor.enrollment as enrollment
from courseware.models import StudentModule
from lms.djangoapps.commerce.signals import create_zendesk_ticket
from lms.djangoapps.commerce.utils import create_zendesk_ticket
from lms.djangoapps.instructor.views.tools import get_student_from_identifier
from student import auth
from student.roles import CourseStaffRole
......
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