From 417ef0583ba03977285468fdb3b02e00fb117cd2 Mon Sep 17 00:00:00 2001 From: "Albert St. Aubin" Date: Mon, 18 Dec 2017 14:35:53 -0500 Subject: [PATCH] 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. --- .../entitlements/api/v1/tests/test_views.py | 108 ++++--- .../djangoapps/entitlements/api/v1/views.py | 155 ++++++---- common/djangoapps/entitlements/signals.py | 6 - lms/djangoapps/commerce/signals.py | 254 +--------------- lms/djangoapps/commerce/tests/test_signals.py | 177 ++---------- lms/djangoapps/commerce/tests/test_utils.py | 157 +++++++++- lms/djangoapps/commerce/utils.py | 271 +++++++++++++++++- lms/djangoapps/instructor/services.py | 2 +- 8 files changed, 613 insertions(+), 517 deletions(-) delete mode 100644 common/djangoapps/entitlements/signals.py diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py index 2b69196597..2190ba290c 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -25,7 +25,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') @@ -345,6 +344,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') @@ -504,8 +505,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 @@ -530,28 +531,24 @@ 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, + content_type='application/json', + ) + assert response.status_code == 204 - # pre_db_changes_entitlement = course_entitlement - revoke_url = url + '?is_refund=true' - response = self.client.delete( - revoke_url, - content_type='application/json', - ) - 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 == - 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 + course_entitlement.refresh_from_db() + 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, @@ -581,18 +578,59 @@ 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, + content_type='application/json', + ) + assert response.status_code == 400 - revoke_url = url + '?is_refund=true' - response = self.client.delete( - revoke_url, - content_type='application/json', - ) - assert response.status_code == 400 + 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 - 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 == 500 + + 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 diff --git a/common/djangoapps/entitlements/api/v1/views.py b/common/djangoapps/entitlements/api/v1/views.py index 52aab7faf9..0ef559643f 100644 --- a/common/djangoapps/entitlements/api/v1/views.py +++ b/common/djangoapps/entitlements/api/v1/views.py @@ -1,6 +1,6 @@ import logging -from django.db import transaction +from django.db import IntegrityError, transaction from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from edx_rest_framework_extensions.authentication import JwtAuthentication @@ -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 @@ -23,6 +23,59 @@ from student.models import CourseEnrollmentException, AlreadyEnrolledError log = logging.getLogger(__name__) +@transaction.atomic +def _unenroll_entitlement(course_entitlement, course_run_key): + """ + Internal method to handle the details of Unenrolling a User in a Course Run. + """ + CourseEnrollment.unenroll(course_entitlement.user, course_run_key, skip_refund=True) + course_entitlement.set_enrollment(None) + + +@transaction.atomic +def _process_revoke_and_unenroll_entitlement(course_entitlement, is_refund=False): + """ + Process the revoke of the Course Entitlement and refund if needed + + Arguments: + course_entitlement: Course Entitlement Object + + is_refund (bool): True if a refund should be processed + + Exceptions: + IntegrityError if there is an issue that should reverse the database changes + """ + if course_entitlement.expired_at is None: + course_entitlement.expired_at = timezone.now() + log.info( + 'Set expired_at to [%s] for course entitlement [%s]', + course_entitlement.expired_at, + course_entitlement.uuid + ) + course_entitlement.save() + + if course_entitlement.enrollment_course_run is not None: + course_id = course_entitlement.enrollment_course_run.course_id + _unenroll_entitlement(course_entitlement, course_id) + log.info( + 'Unenrolled user [%s] from course run [%s] as part of revocation of course entitlement [%s]', + course_entitlement.user.username, + course_id, + course_entitlement.uuid + ) + + if is_refund: + refund_successful = refund_entitlement(course_entitlement=course_entitlement) + if not refund_successful: + # This state is achieved in most cases by a failure in the ecommerce service to process the refund. + log.warn( + 'Entitlement Refund failed for Course Entitlement [%s], alert User', + course_entitlement.uuid + ) + # Force Transaction reset with an Integrity error exception, this will revert all previous transactions + raise IntegrityError + + class EntitlementViewSet(viewsets.ModelViewSet): ENTITLEMENT_UUID4_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' @@ -105,7 +158,10 @@ class EntitlementViewSet(viewsets.ModelViewSet): headers = self.get_success_headers(serializer.data) # Note, the entitlement is re-serialized before getting added to the Response, # so that the 'modified' date reflects changes that occur when upgrading enrollment. - return Response(CourseEntitlementSerializer(entitlement).data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + CourseEntitlementSerializer(entitlement).data, + status=status.HTTP_201_CREATED, headers=headers + ) def retrieve(self, request, *args, **kwargs): """ @@ -139,31 +195,19 @@ class EntitlementViewSet(viewsets.ModelViewSet): def perform_destroy(self, instance): """ - This method is an override and is called by the DELETE method - """ - save_model = False - if instance.expired_at is None: - instance.expired_at = timezone.now() - log.info('Set expired_at to [%s] for course entitlement [%s]', instance.expired_at, instance.uuid) - save_model = True + This method is an override and is called by the destroy method, which is called when a DELETE operation occurs - if instance.enrollment_course_run is not None: - CourseEnrollment.unenroll( - user=instance.user, - course_id=instance.enrollment_course_run.course_id, - skip_refund=True - ) - enrollment = instance.enrollment_course_run - instance.enrollment_course_run = None - save_model = True - log.info( - 'Unenrolled user [%s] from course run [%s] as part of revocation of course entitlement [%s]', - instance.user.username, - enrollment.course_id, - instance.uuid - ) - if save_model: - instance.save() + This method will revoke the User's entitlement and unenroll the user if they are enrolled + in a Course Run + + It is assumed the user has already been refunded. + """ + log.info( + 'Entitlement Revoke requested for Course Entitlement[%s]', + instance.uuid + ) + # This is not called with is_refund=True here because it is assumed the user has already been refunded. + _process_revoke_and_unenroll_entitlement(instance) class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): @@ -188,6 +232,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): return True return False + @transaction.atomic def _enroll_entitlement(self, entitlement, course_run_key, user): """ Internal method to handle the details of enrolling a User in a Course Run. @@ -225,13 +270,6 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): entitlement.set_enrollment(enrollment) return None - def _unenroll_entitlement(self, entitlement, course_run_key, user): - """ - Internal method to handle the details of Unenrolling a User in a Course Run. - """ - CourseEnrollment.unenroll(user, course_run_key, skip_refund=True) - entitlement.set_enrollment(None) - def create(self, request, uuid): """ On POST this method will be called and will handle enrolling a user in the @@ -289,10 +327,9 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): if response: return response elif entitlement.enrollment_course_run.course_id != course_run_id: - self._unenroll_entitlement( - entitlement=entitlement, + _unenroll_entitlement( + course_entitlement=entitlement, course_run_key=entitlement.enrollment_course_run.course_id, - user=request.user ) response = self._enroll_entitlement( entitlement=entitlement, @@ -328,41 +365,33 @@ 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]', + entitlement.uuid + ) - # Revoke the Course Entitlement and issue Refund - log.info( - 'Entitlement Refund requested for Course Entitlement[%s]', - str(entitlement.uuid) - ) + try: + _process_revoke_and_unenroll_entitlement(course_entitlement=entitlement, is_refund=True) + except IntegrityError: + # This state is reached when there was a failure in revoke and refund process resulting + # in a reversion of DB changes + return Response( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + data={ + 'message': 'Entitlement revoke and refund failed due to refund internal process failure' + }) - REFUND_ENTITLEMENT.send(sender=None, course_entitlement=entitlement) - entitlement.expired_at_datetime = timezone.now() - entitlement.save() - - log.info( - 'Set expired_at to [%s] for course entitlement [%s]', - entitlement.expired_at, - entitlement.uuid - ) elif not is_refund: if entitlement.enrollment_course_run is not None: - self._unenroll_entitlement( - entitlement=entitlement, + _unenroll_entitlement( + course_entitlement=entitlement, course_run_key=entitlement.enrollment_course_run.course_id, - user=request.user ) else: log.info( 'Entitlement Refund failed for Course Entitlement [%s]. Entitlement is not refundable', - str(entitlement.uuid) + entitlement.uuid ) return Response( status=status.HTTP_400_BAD_REQUEST, diff --git a/common/djangoapps/entitlements/signals.py b/common/djangoapps/entitlements/signals.py deleted file mode 100644 index 8783699f9e..0000000000 --- a/common/djangoapps/entitlements/signals.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Enrollment track related signals. -""" -from django.dispatch import Signal - -REFUND_ENTITLEMENT = Signal(providing_args=['course_entitlement']) diff --git a/lms/djangoapps/commerce/signals.py b/lms/djangoapps/commerce/signals.py index f9ca113942..eb0846eadb 100644 --- a/lms/djangoapps/commerce/signals.py +++ b/lms/djangoapps/commerce/signals.py @@ -3,24 +3,15 @@ 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_seat log = logging.getLogger(__name__) @@ -57,30 +48,6 @@ def handle_refund_order(sender, course_enrollment=None, **kwargs): ) -# pylint: disable=unused-argument -@receiver(REFUND_ENTITLEMENT) -def handle_refund_entitlement(sender, course_entitlement=None, **kwargs): - if not is_commerce_service_configured(): - return - - if course_entitlement and course_entitlement.is_entitlement_refundable(): - try: - request_user = get_request_user() - if request_user and course_entitlement.user == request_user: - refund_entitlement(course_entitlement) - except Exception as exc: # pylint: disable=broad-except - # don't assume the signal was fired with `send_robust`. - # avoid blowing up other signal handlers by gracefully - # trapping the Exception and logging an error. - 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) - ) - - def get_request_user(): """ Helper to get the authenticated user from the current HTTP request (if @@ -91,220 +58,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) diff --git a/lms/djangoapps/commerce/tests/test_signals.py b/lms/djangoapps/commerce/tests/test_signals.py index d2fd5e8fef..358fc038d6 100644 --- a/lms/djangoapps/commerce/tests/test_signals.py +++ b/lms/djangoapps/commerce/tests/test_signals.py @@ -19,14 +19,12 @@ from opaque_keys.edx.keys import CourseKey from requests import Timeout from course_modes.models import CourseMode -from entitlements.signals import REFUND_ENTITLEMENT -from entitlements.tests.factories import CourseEntitlementFactory from student.signals import REFUND_ORDER 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 +141,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. @@ -156,9 +154,9 @@ class TestRefundSignal(TestCase): with mock_process_refund(failed_refund_id, status=500, reset_on_exit=False): self.send_signal() 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.user, [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. @@ -170,9 +168,9 @@ class TestRefundSignal(TestCase): with mock_create_refund(status=201, response=[refund_id]): self.send_signal() 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.user, [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 +185,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 +195,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 +216,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 +235,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.user, [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 +247,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.user, refund_ids) + body = _generate_refund_notification_body(self.student, refund_ids) mock_zendesk.assert_called_with( self.student.profile.name, self.student.email, @@ -268,13 +266,14 @@ class TestRefundSignal(TestCase): body='I want a refund!', tags=None): """ Call the create_zendesk_ticket function. """ tags = tags or ['auto_refund'] - create_zendesk_ticket(name, email, subject, body, tags) + return create_zendesk_ticket(name, email, subject, body, tags) @override_settings(ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=None, ZENDESK_API_KEY=None) def test_create_zendesk_ticket_no_settings(self): """ Verify the Zendesk API is not called if the settings are not all set. """ with mock.patch('requests.post') as mock_post: - self.call_create_zendesk_ticket() + success = self.call_create_zendesk_ticket() + self.assertFalse(success) self.assertFalse(mock_post.called) def test_create_zendesk_ticket_request_error(self): @@ -284,7 +283,8 @@ class TestRefundSignal(TestCase): We simply need to ensure the exception is not raised beyond the function. """ with mock.patch('requests.post', side_effect=Timeout) as mock_post: - self.call_create_zendesk_ticket() + success = self.call_create_zendesk_ticket() + self.assertFalse(success) self.assertTrue(mock_post.called) @httpretty.activate @@ -297,7 +297,8 @@ class TestRefundSignal(TestCase): subject = 'Test Ticket' body = 'I want a refund!' tags = ['auto_refund'] - self.call_create_zendesk_ticket(name, email, subject, body, tags) + ticket_created = self.call_create_zendesk_ticket(name, email, subject, body, tags) + self.assertTrue(ticket_created) last_request = httpretty.last_request() # Verify the headers @@ -321,139 +322,3 @@ class TestRefundSignal(TestCase): } } self.assertDictEqual(json.loads(last_request.body), expected) - - -@override_settings(ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=ZENDESK_USER, ZENDESK_API_KEY=ZENDESK_API_KEY) -class TestRevokeEntitlementSignal(TestCase): - """ - Exercises logic triggered by the REVOKE_ENTITLEMENT signal. - """ - - def setUp(self): - super(TestRevokeEntitlementSignal, self).setUp() - - # Ensure the E-Commerce service user exists - UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True) - - self.requester = UserFactory(username="test-requester") - self.student = UserFactory( - username="test-student", - email="test-student@example.com", - ) - self.course_entitlement = CourseEntitlementFactory( - user=self.student, - mode=CourseMode.VERIFIED - ) - - self.config = CommerceConfiguration.current() - self.config.enable_automatic_refund_approval = True - self.config.save() - - def send_signal(self): - """ - DRY helper: emit the REVOKE_ENTITLEMENT signal, as is done in - common.djangoapps.entitlements.views after a successful unenrollment and revoke of the entitlement. - """ - REFUND_ENTITLEMENT.send(sender=None, course_entitlement=self.course_entitlement) - - @override_settings( - ECOMMERCE_PUBLIC_URL_ROOT=None, - ECOMMERCE_API_URL=None, - ) - def test_no_service(self): - """ - Ensure that the receiver quietly bypasses attempts to initiate - refunds when there is no external service configured. - """ - with mock.patch('lms.djangoapps.commerce.signals.refund_seat') as mock_refund_entitlement: - self.send_signal() - self.assertFalse(mock_refund_entitlement.called) - - @mock.patch('lms.djangoapps.commerce.signals.get_request_user') - @mock.patch('lms.djangoapps.commerce.signals.refund_entitlement') - def test_receiver(self, mock_refund_entitlement, mock_get_user): - """ - Ensure that the REVOKE_ENTITLEMENT signal triggers correct calls to - refund_entitlement(), when it is appropriate to do so. - """ - mock_get_user.return_value = self.student - self.send_signal() - self.assertTrue(mock_refund_entitlement.called) - self.assertEqual(mock_refund_entitlement.call_args[0], (self.course_entitlement,)) - - # if the course_entitlement is not refundable, we should not try to initiate a refund. - mock_refund_entitlement.reset_mock() - self.course_entitlement.is_entitlement_refundable = mock.Mock(return_value=False) - self.send_signal() - self.assertFalse(mock_refund_entitlement.called) - - @mock.patch('lms.djangoapps.commerce.signals.refund_entitlement') - @mock.patch('lms.djangoapps.commerce.signals.get_request_user', return_value=None) - def test_requester(self, mock_get_request_user, mock_refund_entitlement): - """ - Ensure the right requester is specified when initiating refunds. - """ - # no HTTP request/user: No Refund called. - self.send_signal() - self.assertFalse(mock_refund_entitlement.called) - - # HTTP user is the student: auth to commerce service as the unenrolled student and refund. - mock_get_request_user.return_value = self.student - mock_refund_entitlement.reset_mock() - self.send_signal() - self.assertTrue(mock_refund_entitlement.called) - self.assertEqual(mock_refund_entitlement.call_args[0], (self.course_entitlement,)) - - # HTTP user is another user: No refund invalid user. - mock_get_request_user.return_value = self.requester - mock_refund_entitlement.reset_mock() - self.send_signal() - self.assertFalse(mock_refund_entitlement.called) - - # HTTP user is another server (AnonymousUser): do not try to initiate a refund at all. - mock_get_request_user.return_value = AnonymousUser() - mock_refund_entitlement.reset_mock() - self.send_signal() - self.assertFalse(mock_refund_entitlement.called) - - @mock.patch('lms.djangoapps.commerce.signals.get_request_user',) - @mock.patch('lms.djangoapps.commerce.signals.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. - """ - refund_id = 1 - failed_refund_id = 2 - - with mock_create_refund(status=201, response=[refund_id, failed_refund_id]): - with mock_process_refund(refund_id, reset_on_exit=False): - with mock_process_refund(failed_refund_id, status=500, reset_on_exit=False): - mock_get_user.return_value = self.student - self.send_signal() - self.assertTrue(mock_send_notification.called) - 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') - 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. - """ - refund_id = 1 - self.config.enable_automatic_refund_approval = False - self.config.save() - - with mock_create_refund(status=201, response=[refund_id]): - mock_get_user.return_value = self.student - self.send_signal() - self.assertTrue(mock_send_notification.called) - mock_send_notification.assert_called_with(self.course_entitlement, [refund_id]) - - @mock.patch('openedx.core.djangoapps.theming.helpers.is_request_in_themed_site', return_value=True) - def test_notification_themed_site(self, mock_is_request_in_themed_site): # pylint: disable=unused-argument - """ - Ensure the notification function raises an Exception if used in the - context of themed site. - """ - with self.assertRaises(NotImplementedError): - send_refund_notification(self.course_entitlement, [1, 2, 3]) diff --git a/lms/djangoapps/commerce/tests/test_utils.py b/lms/djangoapps/commerce/tests/test_utils.py index fc2c08297b..092fe80764 100644 --- a/lms/djangoapps/commerce/tests/test_utils.py +++ b/lms/djangoapps/commerce/tests/test_utils.py @@ -1,19 +1,28 @@ """Tests of commerce utilities.""" +import json +import unittest from urllib import urlencode import ddt +import httpretty from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings from mock import patch from waffle.testutils import override_switch +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from course_modes.models import CourseMode +from lms.djangoapps.commerce.models import CommerceConfiguration +from lms.djangoapps.commerce.utils import EcommerceService, refund_entitlement from openedx.core.lib.log_utils import audit_log -from student.tests.factories import UserFactory +from student.tests.factories import (TEST_PASSWORD, UserFactory) -from ..models import CommerceConfiguration -from ..utils import EcommerceService +# Entitlements is not in CMS' INSTALLED_APPS so these imports will error during test collection +if settings.ROOT_URLCONF == 'lms.urls': + from entitlements.tests.factories import CourseEntitlementFactory def update_commerce_config(enabled=False, checkout_page='/test_basket/'): @@ -105,3 +114,145 @@ class EcommerceServiceTests(TestCase): skus=urlencode({'sku': skus}, doseq=True), ) self.assertEqual(url, expected_url) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class RefundUtilMethodTests(ModuleStoreTestCase): + def setUp(self): + super(RefundUtilMethodTests, 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') + + @patch('lms.djangoapps.commerce.utils.is_commerce_service_configured', return_value=False) + def test_ecommerce_service_not_configured(self, mock_commerce_configured): + course_entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + refund_success = refund_entitlement(course_entitlement) + assert mock_commerce_configured.is_called + assert not refund_success + + @httpretty.activate + def test_no_ecommerce_connection_and_failure(self): + httpretty.register_uri( + httpretty.POST, + settings.ECOMMERCE_API_URL + 'refunds/', + status=404, + body='{}', + content_type='application/json' + ) + course_entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + refund_success = refund_entitlement(course_entitlement) + assert not refund_success + + @httpretty.activate + def test_ecommerce_successful_refund(self): + httpretty.register_uri( + httpretty.POST, + settings.ECOMMERCE_API_URL + 'refunds/', + status=201, + body='[1]', + content_type='application/json' + ) + httpretty.register_uri( + httpretty.PUT, + settings.ECOMMERCE_API_URL + 'refunds/1/process/', + status=200, + body=json.dumps({ + "id": 9, + "created": "2017-12-21T18:23:49.468298Z", + "modified": "2017-12-21T18:24:02.741426Z", + "total_credit_excl_tax": "100.00", + "currency": "USD", + "status": "Complete", + "order": 15, + "user": 5 + }), + content_type='application/json' + ) + course_entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + refund_success = refund_entitlement(course_entitlement) + assert refund_success + + @httpretty.activate + @patch('lms.djangoapps.commerce.utils._send_refund_notification', return_value=True) + def test_ecommerce_refund_failed_process_notification_sent(self, mock_send_notification): + httpretty.register_uri( + httpretty.POST, + settings.ECOMMERCE_API_URL + 'refunds/', + status=201, + body='[1]', + content_type='application/json' + ) + httpretty.register_uri( + httpretty.PUT, + settings.ECOMMERCE_API_URL + 'refunds/1/process/', + status=400, + body='{}', + content_type='application/json' + ) + course_entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + refund_success = refund_entitlement(course_entitlement) + assert mock_send_notification.is_called + call_args = list(mock_send_notification.call_args) + assert call_args[0] == (course_entitlement.user, [1]) + assert refund_success + + @httpretty.activate + @patch('lms.djangoapps.commerce.utils._send_refund_notification', return_value=True) + def test_ecommerce_refund_not_verified_notification_for_entitlement(self, mock_send_notification): + """ + Note that we are currently notifying Support whenever a refund require approval for entitlements as + Entitlements are only available in paid modes. This test should be updated if this logic changes + in the future. + + PROFESSIONAL mode is used here although we never auto approve PROFESSIONAL refunds right now + """ + httpretty.register_uri( + httpretty.POST, + settings.ECOMMERCE_API_URL + 'refunds/', + status=201, + body='[1]', + content_type='application/json' + ) + httpretty.register_uri( + httpretty.PUT, + settings.ECOMMERCE_API_URL + 'refunds/1/process/', + status=400, + body='{}', + content_type='application/json' + ) + course_entitlement = CourseEntitlementFactory.create(mode=CourseMode.PROFESSIONAL) + refund_success = refund_entitlement(course_entitlement) + assert mock_send_notification.is_called + call_args = list(mock_send_notification.call_args) + assert call_args[0] == (course_entitlement.user, [1]) + assert refund_success + + @httpretty.activate + @patch('lms.djangoapps.commerce.utils._send_refund_notification', return_value=True) + def test_ecommerce_refund_send_notification_failed(self, mock_send_notification): + httpretty.register_uri( + httpretty.POST, + settings.ECOMMERCE_API_URL + 'refunds/', + status=201, + body='[1]', + content_type='application/json' + ) + httpretty.register_uri( + httpretty.PUT, + settings.ECOMMERCE_API_URL + 'refunds/1/process/', + status=400, + body='{}', + content_type='application/json' + ) + mock_send_notification.side_effect = NotImplementedError + course_entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + refund_success = refund_entitlement(course_entitlement) + + assert mock_send_notification.is_called + call_args = list(mock_send_notification.call_args) + assert call_args[0] == (course_entitlement.user, [1]) + assert not refund_success diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index 30eb070fd5..ac4d8a9c93 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -1,16 +1,24 @@ """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,264 @@ 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.id, + 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.id, + entitlement_uuid + ) + + try: + refund_ids = api_client.refunds.post( + { + 'order_number': course_entitlement.order_number, + 'username': enrollee.id, + '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]", + enrollee.id, + course_entitlement.uuid, + str(exc) + ) + return False + + if refund_ids: + log.info( + 'Refund successfully opened for user [%s], course entitlement [%s]: %r', + enrollee.id, + entitlement_uuid, + refund_ids, + ) + + return _process_refund( + refund_ids=refund_ids, + api_client=api_client, + mode=course_entitlement.mode, + user=enrollee, + always_notify=True, + ) + else: + log.warn('No refund opened for user [%s], course entitlement [%s]', enrollee.id, entitlement_uuid) + return False + + +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.id}) + + 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, + mode=course_enrollment.mode, + user=enrollee, + ) + 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, mode, user, always_notify=False): + """ + Helper method to process a refund for a given course_product. This method assumes that the User has already + been unenrolled. + + Arguments: + refund_ids: List of refund ids to be processed + api_client: The API Client used in the processing of refunds + mode: The mode that the refund should be processed for + user: The user that the refund is being processed for + always_notify (bool): This will enable always notifying support with Zendesk tickets when + an approval is required + + 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: The following assumes that the user has already been unenrolled. + # We are then able to approve payment. 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 mode != 'verified' and not always_notify: + # 'verified' is the only enrollment mode that should presently + # result in opening a refund request. + log.info( + 'Skipping refund support notification for non-verified mode for user [%s], mode: [%s]', + user.id, + mode, + ) + else: + try: + return _send_refund_notification(user, 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(user, refund_ids): + """ + Notify the support team of the refund request. + + Returns: + bool: True if we are able to send the notification. In this case that means we were able to create + a ZenDesk ticket + """ + + 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.") + + # Build the information for the ZenDesk ticket + student = user + subject = _("[Refund] User-Requested Refund") + body = _generate_refund_notification_body(student, refund_ids) + requester_name = student.profile.name or student.username + + return 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. + + Returns: + bool: False if we are unable to create the ticket for any reason + """ + if not (settings.ZENDESK_URL and settings.ZENDESK_USER and settings.ZENDESK_API_KEY): + log.error('Zendesk is not configured. Cannot create a ticket.') + return False + + # Copy the tags to avoid modifying the original list. + tags = set(tags or []) + tags.add('LMS') + tags = list(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) + return False + else: + log.debug('Successfully created ticket.') + except Exception: # pylint: disable=broad-except + log.exception('Failed to create ticket.') + return False + return True diff --git a/lms/djangoapps/instructor/services.py b/lms/djangoapps/instructor/services.py index 55442edf11..d644bfb6f9 100644 --- a/lms/djangoapps/instructor/services.py +++ b/lms/djangoapps/instructor/services.py @@ -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