diff --git a/lms/djangoapps/commerce/signals.py b/lms/djangoapps/commerce/signals.py
index b135325dca..4ab72b620b 100644
--- a/lms/djangoapps/commerce/signals.py
+++ b/lms/djangoapps/commerce/signals.py
@@ -1,26 +1,28 @@
"""
Signal handling functions for use with external commerce service.
"""
+import json
import logging
from urlparse import urljoin
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
-from django.core.mail import EmailMultiAlternatives
from django.dispatch import receiver
from django.utils.translation import ugettext as _
from ecommerce_api_client.exceptions import HttpClientError
+import requests
+
from microsite_configuration import microsite
from request_cache.middleware import RequestCache
from student.models import UNENROLL_DONE
-
from commerce import ecommerce_api_client, is_commerce_service_configured
log = logging.getLogger(__name__)
@receiver(UNENROLL_DONE)
-def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, **kwargs): # pylint: disable=unused-argument
+def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False,
+ **kwargs): # pylint: disable=unused-argument
"""
Signal receiver for unenrollments, used to automatically initiate refunds
when applicable.
@@ -140,33 +142,77 @@ def refund_seat(course_enrollment, request_user):
return refund_ids
-def send_refund_notification(course_enrollment, refund_ids):
- """
- Issue an email notification to the configured email recipient about a
- newly-initiated refund request.
+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))
+
+
+def send_refund_notification(course_enrollment, refund_ids):
+ """ Notify the support team of the refund request. """
+
+ tags = ['auto_refund']
- This function does not do any exception handling; callers are responsible
- for capturing and recovering from any errors.
- """
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.")
- for_user = course_enrollment.user
+ student = course_enrollment.user
subject = _("[Refund] User-Requested Refund")
- message = _(
- "A refund request has been initiated for {username} ({email}). "
- "To process this request, please visit the link(s) below."
- ).format(username=for_user.username, email=for_user.email)
-
- refund_urls = [
- urljoin(settings.ECOMMERCE_PUBLIC_URL_ROOT, '/dashboard/refunds/{}/'.format(refund_id))
- for refund_id in refund_ids
- ]
- text_body = '\r\n'.join([message] + refund_urls + [''])
- refund_links = ['{0}'.format(url) for url in refund_urls]
- html_body = '
{}
'.format('
'.join([message] + refund_links))
-
- email_message = EmailMultiAlternatives(subject, text_body, for_user.email, [settings.PAYMENT_SUPPORT_EMAIL])
- email_message.attach_alternative(html_body, "text/html")
- email_message.send()
+ 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/__init__.py b/lms/djangoapps/commerce/tests/__init__.py
index 4af326a662..394969aeb2 100644
--- a/lms/djangoapps/commerce/tests/__init__.py
+++ b/lms/djangoapps/commerce/tests/__init__.py
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
""" Commerce app tests package. """
-import json
from django.test import TestCase
from django.test.utils import override_settings
@@ -11,7 +10,7 @@ import mock
from commerce import ecommerce_api_client
from student.tests.factories import UserFactory
-
+JSON = 'application/json'
TEST_PUBLIC_URL_ROOT = 'http://www.example.com'
TEST_API_URL = 'http://www-internal.example.com/api'
TEST_API_SIGNING_KEY = 'edx'
@@ -48,7 +47,7 @@ class EcommerceApiClientTest(TestCase):
httpretty.POST,
'{}/baskets/1/'.format(TEST_API_URL),
status=200, body='{}',
- adding_headers={'Content-Type': 'application/json'}
+ adding_headers={'Content-Type': JSON}
)
mock_tracker = mock.Mock()
mock_tracker.resolve_context = mock.Mock(return_value={'client_id': self.TEST_CLIENT_ID})
@@ -82,7 +81,7 @@ class EcommerceApiClientTest(TestCase):
httpretty.GET,
'{}/baskets/1/order/'.format(TEST_API_URL),
status=200, body=expected_content,
- adding_headers={'Content-Type': 'application/json'},
+ adding_headers={'Content-Type': JSON},
)
actual_object = ecommerce_api_client(self.user).baskets(1).order.get()
self.assertEqual(actual_object, {u"result": u"Préparatoire"})
diff --git a/lms/djangoapps/commerce/tests/test_signals.py b/lms/djangoapps/commerce/tests/test_signals.py
index 014d572a57..9e37edbb66 100644
--- a/lms/djangoapps/commerce/tests/test_signals.py
+++ b/lms/djangoapps/commerce/tests/test_signals.py
@@ -1,27 +1,37 @@
"""
Tests for signal handling in commerce djangoapp.
"""
+import base64
+import json
+from urlparse import urljoin
+
+import ddt
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.test.utils import override_settings
-
-from course_modes.models import CourseMode
-import ddt
+import httpretty
import mock
from opaque_keys.edx.keys import CourseKey
+from requests import Timeout
+
from student.models import UNENROLL_DONE
from student.tests.factories import UserFactory, CourseEnrollmentFactory
-
-from commerce.signals import refund_seat, send_refund_notification
-from commerce.tests import TEST_PUBLIC_URL_ROOT, TEST_API_URL, TEST_API_SIGNING_KEY
+from commerce.signals import (refund_seat, send_refund_notification, generate_refund_notification_body,
+ create_zendesk_ticket)
+from commerce.tests import TEST_PUBLIC_URL_ROOT, TEST_API_URL, TEST_API_SIGNING_KEY, JSON
from commerce.tests.mocks import mock_create_refund
+from course_modes.models import CourseMode
+
+ZENDESK_URL = 'http://zendesk.example.com/'
+ZENDESK_USER = 'test@example.com'
+ZENDESK_API_KEY = 'abc123'
@ddt.ddt
@override_settings(
ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT,
- ECOMMERCE_API_URL=TEST_API_URL,
- ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
+ ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
+ ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=ZENDESK_USER, ZENDESK_API_KEY=ZENDESK_API_KEY
)
class TestRefundSignal(TestCase):
"""
@@ -197,40 +207,75 @@ class TestRefundSignal(TestCase):
with self.assertRaises(NotImplementedError):
send_refund_notification(self.course_enrollment, [1, 2, 3])
- @override_settings(PAYMENT_SUPPORT_EMAIL='payment@example.com')
- @mock.patch('commerce.signals.EmailMultiAlternatives')
- def test_notification_content(self, mock_email_class):
+ def test_send_refund_notification(self):
+ """ Verify the support team is notified of the refund request. """
+
+ with mock.patch('commerce.signals.create_zendesk_ticket') as mock_zendesk:
+ refund_ids = [1, 2, 3]
+ 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,
+ "[Refund] User-Requested Refund", body, ['auto_refund'])
+
+ def _mock_zendesk_api(self, status=201):
+ """ Mock Zendesk's ticket creation API. """
+ httpretty.register_uri(httpretty.POST, urljoin(ZENDESK_URL, '/api/v2/tickets.json'), status=status,
+ body='{}', content_type=JSON)
+
+ def call_create_zendesk_ticket(self, name=u'Test user', email=u'user@example.com', subject=u'Test Ticket',
+ body=u'I want a refund!', tags=None):
+ """ Call the create_zendesk_ticket function. """
+ tags = tags or [u'auto_refund']
+ 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()
+ self.assertFalse(mock_post.called)
+
+ def test_create_zendesk_ticket_request_error(self):
"""
- Ensure the email sender, recipient, subject, content type, and content
- are all correct.
+ Verify exceptions are handled appropriately if the request to the Zendesk API fails.
+
+ We simply need to ensure the exception is not raised beyond the function.
"""
- # mock_email_class is the email message class/constructor.
- # mock_message is the instance returned by the constructor.
- # we need to make assertions regarding both.
- mock_message = mock.MagicMock()
- mock_email_class.return_value = mock_message
+ with mock.patch('requests.post', side_effect=Timeout) as mock_post:
+ self.call_create_zendesk_ticket()
+ self.assertTrue(mock_post.called)
- refund_ids = [1, 2, 3]
- send_refund_notification(self.course_enrollment, refund_ids)
+ @httpretty.activate
+ def test_create_zendesk_ticket(self):
+ """ Verify the Zendesk API is called. """
+ self._mock_zendesk_api()
- # check headers and text content
- self.assertEqual(
- mock_email_class.call_args[0],
- ("[Refund] User-Requested Refund", mock.ANY, self.student.email, ['payment@example.com']),
- )
- text_body = mock_email_class.call_args[0][1]
- # check for a URL for each refund
- for exp in [r'{0}/dashboard/refunds/{1}/'.format(TEST_PUBLIC_URL_ROOT, refund_id)
- for refund_id in refund_ids]:
- self.assertRegexpMatches(text_body, exp)
+ name = u'Test user'
+ email = u'user@example.com'
+ subject = u'Test Ticket'
+ body = u'I want a refund!'
+ tags = [u'auto_refund']
+ self.call_create_zendesk_ticket(name, email, subject, body, tags)
+ last_request = httpretty.last_request()
- # check HTML content
- self.assertEqual(mock_message.attach_alternative.call_args[0], (mock.ANY, "text/html"))
- html_body = mock_message.attach_alternative.call_args[0][0]
- # check for a link to each refund
- for exp in [r'a href="{0}/dashboard/refunds/{1}/"'.format(TEST_PUBLIC_URL_ROOT, refund_id)
- for refund_id in refund_ids]:
- self.assertRegexpMatches(html_body, exp)
+ # Verify the headers
+ expected = {
+ 'content-type': JSON,
+ 'Authorization': 'Basic ' + base64.b64encode(
+ '{user}/token:{pwd}'.format(user=ZENDESK_USER, pwd=ZENDESK_API_KEY))
+ }
+ self.assertDictContainsSubset(expected, last_request.headers)
- # make sure we actually SEND the message too.
- self.assertTrue(mock_message.send.called)
+ # Verify the content
+ expected = {
+ u'ticket': {
+ u'requester': {
+ u'name': name,
+ u'email': email
+ },
+ u'subject': subject,
+ u'comment': {u'body': body},
+ u'tags': [u'LMS'] + tags
+ }
+ }
+ self.assertDictEqual(json.loads(last_request.body), expected)