signals.py 8.48 KB
Newer Older
1 2 3
"""
Signal handling functions for use with external commerce service.
"""
4
import json
5
import logging
6
from urlparse import urljoin
7 8

from django.conf import settings
9
from django.contrib.auth.models import AnonymousUser
10 11
from django.dispatch import receiver
from django.utils.translation import ugettext as _
12
from edx_rest_api_client.exceptions import HttpClientError
13 14
import requests

15 16 17
from microsite_configuration import microsite
from request_cache.middleware import RequestCache
from student.models import UNENROLL_DONE
18
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
19 20 21 22 23

log = logging.getLogger(__name__)


@receiver(UNENROLL_DONE)
24 25
def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False,
                         **kwargs):  # pylint: disable=unused-argument
26 27 28 29 30 31 32 33 34 35 36 37
    """
    Signal receiver for unenrollments, used to automatically initiate refunds
    when applicable.

    N.B. this signal is also consumed by lms.djangoapps.shoppingcart.
    """
    if not is_commerce_service_configured() or skip_refund:
        return

    if course_enrollment and course_enrollment.refundable():
        try:
            request_user = get_request_user() or course_enrollment.user
38 39 40 41 42 43 44
            if isinstance(request_user, AnonymousUser):
                # Assume the request was initiated via server-to-server
                # api call (presumably Otto).  In this case we cannot
                # construct a client to call Otto back anyway, because
                # the client does not work anonymously, and furthermore,
                # there's certainly no need to inform Otto about this request.
                return
45 46 47 48 49 50 51
            refund_seat(course_enrollment, request_user)
        except:  # pylint: disable=bare-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 [%s]",
52
                course_enrollment.user.id,
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94
                course_enrollment.course_id,
            )


def get_request_user():
    """
    Helper to get the authenticated user from the current HTTP request (if
    applicable).

    If the requester of an unenrollment is not the same person as the student
    being unenrolled, we authenticate to the commerce service as the requester.
    """
    request = RequestCache.get_current_request()
    return getattr(request, 'user', None)


def refund_seat(course_enrollment, request_user):
    """
    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
        request_user: the user as whom to authenticate to the commerce service
            when attempting to initiate the refund.

    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 commerce service.
        exceptions.Timeout: if the attempt to reach the commerce service timed
            out.

    """
    course_key_str = unicode(course_enrollment.course_id)
    unenrolled_user = course_enrollment.user

    try:
        refund_ids = ecommerce_api_client(request_user or unenrolled_user).refunds.post(
95
            {'course_id': course_key_str, 'username': unenrolled_user.username}
96 97 98 99 100 101 102
        )
    except HttpClientError, exc:
        if exc.response.status_code == 403 and request_user != unenrolled_user:
            # this is a known limitation; commerce service does not presently
            # support the case of a non-superusers initiating a refund on
            # behalf of another user.
            log.warning("User [%s] was not authorized to initiate a refund for user [%s] "
103
                        "upon unenrollment from course [%s]", request_user.id, unenrolled_user.id, course_key_str)
104 105 106 107 108 109 110 111 112 113 114 115 116
            return []
        else:
            # no other error is anticipated, so re-raise the Exception
            raise exc

    if refund_ids:
        # at least one refundable order was found.
        log.info(
            "Refund successfully opened for user [%s], course [%s]: %r",
            unenrolled_user.id,
            course_key_str,
            refund_ids,
        )
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137

        # 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_enrollment.mode != 'verified':
            # 'verified' is the only enrollment mode that should presently
            # result in opening a refund request.
            log.info(
                "Skipping refund email notification for non-verified mode for user [%s], course [%s], mode: [%s]",
                course_enrollment.user.id,
                course_enrollment.course_id,
                course_enrollment.mode,
            )
        else:
            try:
                send_refund_notification(course_enrollment, refund_ids)
            except:  # pylint: disable=bare-except
                # don't break, just log a warning
                log.warning("Could not send email notification for refund.", exc_info=True)
138 139 140 141 142 143 144
    else:
        # no refundable orders were found.
        log.debug("No refund opened for user [%s], course [%s]", unenrolled_user.id, course_key_str)

    return refund_ids


145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
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(u'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)

    refund_urls = [urljoin(settings.ECOMMERCE_PUBLIC_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))


205
def send_refund_notification(course_enrollment, refund_ids):
206 207 208
    """ Notify the support team of the refund request. """

    tags = ['auto_refund']
209 210 211 212 213

    if microsite.is_request_in_microsite():
        # this is not presently supported with the external service.
        raise NotImplementedError("Unable to send refund processing emails to microsite teams.")

214
    student = course_enrollment.user
215
    subject = _("[Refund] User-Requested Refund")
216 217 218
    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)