diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index 30eb070fd5..e4bcddde99 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.username, + 'entitlement_uuid': entitlement_uuid, + } + ) + except Exception as exc: # pylint: disable=broad-except + # Catch any possible exceptions from the Ecommerce service to ensure we fail gracefully + log.exception( + "Unexpected exception while attempting to initiate refund for user [%s], " + "course entitlement [%s] message: [%s]", + 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.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, + 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