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': ...@@ -22,7 +22,6 @@ if settings.ROOT_URLCONF == 'lms.urls':
from entitlements.tests.factories import CourseEntitlementFactory from entitlements.tests.factories import CourseEntitlementFactory
from entitlements.models import CourseEntitlement from entitlements.models import CourseEntitlement
from entitlements.api.v1.serializers import CourseEntitlementSerializer 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') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
...@@ -272,6 +271,8 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): ...@@ -272,6 +271,8 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
def setUp(self): def setUp(self):
super(EntitlementEnrollmentViewSetTest, self).setUp() super(EntitlementEnrollmentViewSetTest, self).setUp()
self.user = UserFactory() self.user = UserFactory()
UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True)
self.client.login(username=self.user.username, password=TEST_PASSWORD) self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course') 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') self.course2 = CourseFactory.create(org='edX', number='DemoX2', display_name='Demo_Course 2')
...@@ -431,8 +432,8 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): ...@@ -431,8 +432,8 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.data['message'] == expected_message # pylint: disable=no-member assert response.data['message'] == expected_message # pylint: disable=no-member
assert not CourseEnrollment.is_enrolled(self.user, fake_course_key) 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.refund_entitlement', return_value=True)
@patch("entitlements.api.v1.views.get_course_runs_for_course") @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): def test_user_can_revoke_and_refund(self, mock_get_course_runs, mock_refund_entitlement):
course_entitlement = CourseEntitlementFactory.create(user=self.user) course_entitlement = CourseEntitlementFactory.create(user=self.user)
mock_get_course_runs.return_value = self.return_values mock_get_course_runs.return_value = self.return_values
...@@ -457,10 +458,6 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): ...@@ -457,10 +458,6 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert CourseEnrollment.is_enrolled(self.user, self.course.id) assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# Unenroll with Revoke for refund # 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' revoke_url = url + '?is_refund=true'
response = self.client.delete( response = self.client.delete(
revoke_url, revoke_url,
...@@ -469,16 +466,16 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): ...@@ -469,16 +466,16 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.status_code == 204 assert response.status_code == 204
course_entitlement.refresh_from_db() course_entitlement.refresh_from_db()
assert mock_refund_handler.called assert mock_refund_entitlement.is_called
assert (CourseEntitlementSerializer(mock_refund_handler.call_args[1]['course_entitlement']).data == assert (CourseEntitlementSerializer(mock_refund_entitlement.call_args[1]['course_entitlement']).data ==
CourseEntitlementSerializer(course_entitlement).data) CourseEntitlementSerializer(course_entitlement).data)
assert not CourseEnrollment.is_enrolled(self.user, self.course.id) assert not CourseEnrollment.is_enrolled(self.user, self.course.id)
assert course_entitlement.enrollment_course_run is None assert course_entitlement.enrollment_course_run is None
assert course_entitlement.expired_at is not None assert course_entitlement.expired_at is not None
@patch('entitlements.api.v1.views.CourseEntitlement.is_entitlement_refundable', return_value=False) @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.refund_entitlement', return_value=True)
@patch("entitlements.api.v1.views.get_course_runs_for_course") @patch('entitlements.api.v1.views.get_course_runs_for_course')
def test_user_can_revoke_and_no_refund_available( def test_user_can_revoke_and_no_refund_available(
self, self,
mock_get_course_runs, mock_get_course_runs,
...@@ -508,9 +505,6 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): ...@@ -508,9 +505,6 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert CourseEnrollment.is_enrolled(self.user, self.course.id) assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# Unenroll with Revoke for refund # 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' revoke_url = url + '?is_refund=true'
response = self.client.delete( response = self.client.delete(
revoke_url, revoke_url,
...@@ -519,7 +513,51 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): ...@@ -519,7 +513,51 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.status_code == 400 assert response.status_code == 400
course_entitlement.refresh_from_db() 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 CourseEnrollment.is_enrolled(self.user, self.course.id)
assert course_entitlement.enrollment_course_run is not None assert course_entitlement.enrollment_course_run is not None
assert course_entitlement.expired_at is None assert course_entitlement.expired_at is None
...@@ -14,7 +14,7 @@ from entitlements.api.v1.filters import CourseEntitlementFilter ...@@ -14,7 +14,7 @@ from entitlements.api.v1.filters import CourseEntitlementFilter
from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly
from entitlements.api.v1.serializers import CourseEntitlementSerializer from entitlements.api.v1.serializers import CourseEntitlementSerializer
from entitlements.models import CourseEntitlement 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.catalog.utils import get_course_runs_for_course
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -274,22 +274,15 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): ...@@ -274,22 +274,15 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
) )
if is_refund and entitlement.is_entitlement_refundable(): 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 # Revoke the Course Entitlement and issue Refund
log.info( log.info(
'Entitlement Refund requested for Course Entitlement[%s]', 'Entitlement Refund requested for Course Entitlement[%s]',
str(entitlement.uuid) 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.expired_at_datetime = timezone.now()
entitlement.save() entitlement.save()
...@@ -298,6 +291,26 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): ...@@ -298,6 +291,26 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
entitlement.expired_at, entitlement.expired_at,
entitlement.uuid 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: elif not is_refund:
if entitlement.enrollment_course_run is not None: if entitlement.enrollment_course_run is not None:
self._unenroll_entitlement( self._unenroll_entitlement(
......
...@@ -3,24 +3,16 @@ Signal handling functions for use with external commerce service. ...@@ -3,24 +3,16 @@ Signal handling functions for use with external commerce service.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
import json
import logging 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.contrib.auth.models import AnonymousUser
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext as _
from entitlements.signals import REFUND_ENTITLEMENT from entitlements.signals import REFUND_ENTITLEMENT
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured from openedx.core.djangoapps.commerce.utils import 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 request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
from student.signals import REFUND_ORDER from student.signals import REFUND_ORDER
from .models import CommerceConfiguration from .utils import refund_entitlement, refund_seat
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -91,220 +83,3 @@ def get_request_user(): ...@@ -91,220 +83,3 @@ def get_request_user():
""" """
request = RequestCache.get_current_request() request = RequestCache.get_current_request()
return getattr(request, 'user', None) 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 ...@@ -26,7 +26,7 @@ from student.tests.factories import CourseEnrollmentFactory, UserFactory
from . import JSON from . import JSON
from .mocks import mock_create_refund, mock_process_refund from .mocks import mock_create_refund, mock_process_refund
from ..models import CommerceConfiguration 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_URL = 'http://zendesk.example.com/'
ZENDESK_USER = 'test@example.com' ZENDESK_USER = 'test@example.com'
...@@ -143,7 +143,7 @@ class TestRefundSignal(TestCase): ...@@ -143,7 +143,7 @@ class TestRefundSignal(TestCase):
self.send_signal() self.send_signal()
self.assertTrue(mock_log_exception.called) 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): 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. Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
...@@ -158,7 +158,7 @@ class TestRefundSignal(TestCase): ...@@ -158,7 +158,7 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_send_notification.called) self.assertTrue(mock_send_notification.called)
mock_send_notification.assert_called_with(self.course_enrollment, [failed_refund_id]) 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): def test_notification_if_automatic_approval_disabled(self, mock_send_notification):
""" """
Ensure the notification is always sent if the automatic approval functionality is disabled. Ensure the notification is always sent if the automatic approval functionality is disabled.
...@@ -172,7 +172,7 @@ class TestRefundSignal(TestCase): ...@@ -172,7 +172,7 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_send_notification.called) self.assertTrue(mock_send_notification.called)
mock_send_notification.assert_called_with(self.course_enrollment, [refund_id]) 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): 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. Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
...@@ -187,7 +187,7 @@ class TestRefundSignal(TestCase): ...@@ -187,7 +187,7 @@ class TestRefundSignal(TestCase):
last_request = httpretty.last_request() last_request = httpretty.last_request()
self.assertDictEqual(json.loads(last_request.body), {'action': 'approve_payment_only'}) 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): def test_notification_no_refund(self, mock_send_notification):
""" """
Ensure the notification function is NOT triggered when no refunds are Ensure the notification function is NOT triggered when no refunds are
...@@ -197,7 +197,7 @@ class TestRefundSignal(TestCase): ...@@ -197,7 +197,7 @@ class TestRefundSignal(TestCase):
self.send_signal() self.send_signal()
self.assertFalse(mock_send_notification.called) 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( @ddt.data(
CourseMode.HONOR, CourseMode.HONOR,
CourseMode.PROFESSIONAL, CourseMode.PROFESSIONAL,
...@@ -218,8 +218,8 @@ class TestRefundSignal(TestCase): ...@@ -218,8 +218,8 @@ class TestRefundSignal(TestCase):
self.send_signal() self.send_signal()
self.assertFalse(mock_send_notification.called) self.assertFalse(mock_send_notification.called)
@mock.patch('lms.djangoapps.commerce.signals.send_refund_notification', side_effect=Exception("Splat!")) @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification', side_effect=Exception("Splat!"))
@mock.patch('lms.djangoapps.commerce.signals.log.warning') @mock.patch('lms.djangoapps.commerce.utils.log.warning')
def test_notification_error(self, mock_log_warning, mock_send_notification): def test_notification_error(self, mock_log_warning, mock_send_notification):
""" """
Ensure an error occuring during notification does not break program Ensure an error occuring during notification does not break program
...@@ -237,10 +237,10 @@ class TestRefundSignal(TestCase): ...@@ -237,10 +237,10 @@ class TestRefundSignal(TestCase):
context of themed site. context of themed site.
""" """
with self.assertRaises(NotImplementedError): 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') @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): def test_send_refund_notification(self, student_email, mock_zendesk):
""" Verify the support team is notified of the refund request. """ """ Verify the support team is notified of the refund request. """
refund_ids = [1, 2, 3] refund_ids = [1, 2, 3]
...@@ -249,8 +249,8 @@ class TestRefundSignal(TestCase): ...@@ -249,8 +249,8 @@ class TestRefundSignal(TestCase):
# generate_refund_notification_body can handle formatting a unicode # generate_refund_notification_body can handle formatting a unicode
# message # message
self.student.email = student_email self.student.email = student_email
send_refund_notification(self.course_enrollment, refund_ids) _send_refund_notification(self.course_enrollment, refund_ids)
body = generate_refund_notification_body(self.student, refund_ids) body = _generate_refund_notification_body(self.student, refund_ids)
mock_zendesk.assert_called_with( mock_zendesk.assert_called_with(
self.student.profile.name, self.student.profile.name,
self.student.email, self.student.email,
...@@ -417,7 +417,7 @@ class TestRevokeEntitlementSignal(TestCase): ...@@ -417,7 +417,7 @@ class TestRevokeEntitlementSignal(TestCase):
self.assertFalse(mock_refund_entitlement.called) self.assertFalse(mock_refund_entitlement.called)
@mock.patch('lms.djangoapps.commerce.signals.get_request_user',) @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): 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. Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
...@@ -434,7 +434,7 @@ class TestRevokeEntitlementSignal(TestCase): ...@@ -434,7 +434,7 @@ class TestRevokeEntitlementSignal(TestCase):
mock_send_notification.assert_called_with(self.course_entitlement, [failed_refund_id]) 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.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): 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. Ensure the notification is always sent if the automatic approval functionality is disabled.
...@@ -456,4 +456,4 @@ class TestRevokeEntitlementSignal(TestCase): ...@@ -456,4 +456,4 @@ class TestRevokeEntitlementSignal(TestCase):
context of themed site. context of themed site.
""" """
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
send_refund_notification(self.course_entitlement, [1, 2, 3]) _send_refund_notification(self.course_entitlement, [1, 2, 3])
...@@ -11,7 +11,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey ...@@ -11,7 +11,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
import lms.djangoapps.instructor.enrollment as enrollment import lms.djangoapps.instructor.enrollment as enrollment
from courseware.models import StudentModule 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 lms.djangoapps.instructor.views.tools import get_student_from_identifier
from student import auth from student import auth
from student.roles import CourseStaffRole 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