diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py index 2190ba290c..2b69196597 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -25,6 +25,7 @@ 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') @@ -344,8 +345,6 @@ 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') @@ -505,8 +504,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('entitlements.api.v1.views.refund_entitlement', return_value=True) - @patch('entitlements.api.v1.views.get_course_runs_for_course') + @patch('lms.djangoapps.commerce.signals.refund_entitlement', return_value=[1]) + @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 @@ -531,24 +530,28 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): 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 == 204 + with patch('lms.djangoapps.commerce.signals.handle_refund_entitlement') as mock_refund_handler: + REFUND_ENTITLEMENT.connect(mock_refund_handler) - 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 + # 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 @patch('entitlements.api.v1.views.CourseEntitlement.is_entitlement_refundable', return_value=False) - @patch('entitlements.api.v1.views.refund_entitlement', return_value=True) - @patch('entitlements.api.v1.views.get_course_runs_for_course') + @patch('lms.djangoapps.commerce.signals.refund_entitlement', return_value=[1]) + @patch("entitlements.api.v1.views.get_course_runs_for_course") def test_user_can_revoke_and_no_refund_available( self, mock_get_course_runs, @@ -578,59 +581,18 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): 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 == 400 + with patch('lms.djangoapps.commerce.signals.handle_refund_entitlement') as mock_refund_handler: + REFUND_ENTITLEMENT.connect(mock_refund_handler) - 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 + revoke_url = url + '?is_refund=true' + response = self.client.delete( + revoke_url, + content_type='application/json', + ) + assert response.status_code == 400 - @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 + 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 diff --git a/common/djangoapps/entitlements/api/v1/views.py b/common/djangoapps/entitlements/api/v1/views.py index 0ef559643f..52aab7faf9 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 IntegrityError, transaction +from django.db import 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 lms.djangoapps.commerce.utils import refund_entitlement +from entitlements.signals 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,59 +23,6 @@ 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}' @@ -158,10 +105,7 @@ 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): """ @@ -195,19 +139,31 @@ class EntitlementViewSet(viewsets.ModelViewSet): def perform_destroy(self, instance): """ - This method is an override and is called by the destroy method, which is called when a DELETE operation occurs - - 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. + This method is an override and is called by the DELETE method """ - 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) + 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 + + 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() class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): @@ -232,7 +188,6 @@ 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. @@ -270,6 +225,13 @@ 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 @@ -327,9 +289,10 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): if response: return response elif entitlement.enrollment_course_run.course_id != course_run_id: - _unenroll_entitlement( - course_entitlement=entitlement, + self._unenroll_entitlement( + entitlement=entitlement, course_run_key=entitlement.enrollment_course_run.course_id, + user=request.user ) response = self._enroll_entitlement( entitlement=entitlement, @@ -365,33 +328,41 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): ) if is_refund and entitlement.is_entitlement_refundable(): - # Revoke the Course Entitlement and issue Refund - log.info( - 'Entitlement Refund requested for Course Entitlement[%s]', - entitlement.uuid - ) + 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 + ) - 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' - }) + # Revoke the Course Entitlement and issue Refund + log.info( + 'Entitlement Refund requested for Course Entitlement[%s]', + str(entitlement.uuid) + ) + 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: - _unenroll_entitlement( - course_entitlement=entitlement, + self._unenroll_entitlement( + 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', - entitlement.uuid + str(entitlement.uuid) ) return Response( status=status.HTTP_400_BAD_REQUEST, diff --git a/common/djangoapps/entitlements/signals.py b/common/djangoapps/entitlements/signals.py new file mode 100644 index 0000000000..8783699f9e --- /dev/null +++ b/common/djangoapps/entitlements/signals.py @@ -0,0 +1,6 @@ +""" +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 eb0846eadb..f9ca113942 100644 --- a/lms/djangoapps/commerce/signals.py +++ b/lms/djangoapps/commerce/signals.py @@ -3,15 +3,24 @@ 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 openedx.core.djangoapps.commerce.utils import is_commerce_service_configured +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 request_cache.middleware import RequestCache from student.signals import REFUND_ORDER -from .utils import refund_seat +from .models import CommerceConfiguration log = logging.getLogger(__name__) @@ -48,6 +57,30 @@ 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 @@ -58,3 +91,220 @@ 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 358fc038d6..d2fd5e8fef 100644 --- a/lms/djangoapps/commerce/tests/test_signals.py +++ b/lms/djangoapps/commerce/tests/test_signals.py @@ -19,12 +19,14 @@ 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 ..utils import create_zendesk_ticket, _generate_refund_notification_body, _send_refund_notification +from ..signals import create_zendesk_ticket, generate_refund_notification_body, send_refund_notification ZENDESK_URL = 'http://zendesk.example.com/' ZENDESK_USER = 'test@example.com' @@ -141,7 +143,7 @@ class TestRefundSignal(TestCase): self.send_signal() self.assertTrue(mock_log_exception.called) - @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification') + @mock.patch('lms.djangoapps.commerce.signals.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. @@ -154,9 +156,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.user, [failed_refund_id]) + mock_send_notification.assert_called_with(self.course_enrollment, [failed_refund_id]) - @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification') + @mock.patch('lms.djangoapps.commerce.signals.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. @@ -168,9 +170,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.user, [refund_id]) + mock_send_notification.assert_called_with(self.course_enrollment, [refund_id]) - @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification') + @mock.patch('lms.djangoapps.commerce.signals.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. @@ -185,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.utils._send_refund_notification') + @mock.patch('lms.djangoapps.commerce.signals.send_refund_notification') def test_notification_no_refund(self, mock_send_notification): """ Ensure the notification function is NOT triggered when no refunds are @@ -195,7 +197,7 @@ class TestRefundSignal(TestCase): self.send_signal() self.assertFalse(mock_send_notification.called) - @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification') + @mock.patch('lms.djangoapps.commerce.signals.send_refund_notification') @ddt.data( CourseMode.HONOR, CourseMode.PROFESSIONAL, @@ -216,8 +218,8 @@ class TestRefundSignal(TestCase): self.send_signal() self.assertFalse(mock_send_notification.called) - @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification', side_effect=Exception("Splat!")) - @mock.patch('lms.djangoapps.commerce.utils.log.warning') + @mock.patch('lms.djangoapps.commerce.signals.send_refund_notification', side_effect=Exception("Splat!")) + @mock.patch('lms.djangoapps.commerce.signals.log.warning') def test_notification_error(self, mock_log_warning, mock_send_notification): """ Ensure an error occuring during notification does not break program @@ -235,10 +237,10 @@ class TestRefundSignal(TestCase): context of themed site. """ with self.assertRaises(NotImplementedError): - _send_refund_notification(self.course_enrollment.user, [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.utils.create_zendesk_ticket') + @mock.patch('lms.djangoapps.commerce.signals.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] @@ -247,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.user, 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, @@ -266,14 +268,13 @@ class TestRefundSignal(TestCase): body='I want a refund!', tags=None): """ Call the create_zendesk_ticket function. """ tags = tags or ['auto_refund'] - return create_zendesk_ticket(name, email, subject, body, tags) + 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: - success = self.call_create_zendesk_ticket() - self.assertFalse(success) + self.call_create_zendesk_ticket() self.assertFalse(mock_post.called) def test_create_zendesk_ticket_request_error(self): @@ -283,8 +284,7 @@ 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: - success = self.call_create_zendesk_ticket() - self.assertFalse(success) + self.call_create_zendesk_ticket() self.assertTrue(mock_post.called) @httpretty.activate @@ -297,8 +297,7 @@ class TestRefundSignal(TestCase): subject = 'Test Ticket' body = 'I want a refund!' tags = ['auto_refund'] - ticket_created = self.call_create_zendesk_ticket(name, email, subject, body, tags) - self.assertTrue(ticket_created) + self.call_create_zendesk_ticket(name, email, subject, body, tags) last_request = httpretty.last_request() # Verify the headers @@ -322,3 +321,139 @@ 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 092fe80764..fc2c08297b 100644 --- a/lms/djangoapps/commerce/tests/test_utils.py +++ b/lms/djangoapps/commerce/tests/test_utils.py @@ -1,28 +1,19 @@ """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 (TEST_PASSWORD, UserFactory) +from student.tests.factories import UserFactory -# 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 +from ..models import CommerceConfiguration +from ..utils import EcommerceService def update_commerce_config(enabled=False, checkout_page='/test_basket/'): @@ -114,145 +105,3 @@ 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 ac4d8a9c93..30eb070fd5 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -1,23 +1,15 @@ """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__) +from .models import CommerceConfiguration def is_account_activation_requirement_disabled(): @@ -117,264 +109,3 @@ 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 d644bfb6f9..55442edf11 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.utils import create_zendesk_ticket +from lms.djangoapps.commerce.signals import create_zendesk_ticket from lms.djangoapps.instructor.views.tools import get_student_from_identifier from student import auth from student.roles import CourseStaffRole