Merge pull request #17095 from edx/revert-16934-aj/LEARNER-3629_refund_api_refactor

Revert "Refactor of the CourseEntitlement Refund API to handle refund failures"
This commit is contained in:
Albert (AJ) St. Aubin
2018-01-04 14:32:16 -05:00
committed by GitHub
8 changed files with 517 additions and 613 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
"""
Enrollment track related signals.
"""
from django.dispatch import Signal
REFUND_ENTITLEMENT = Signal(providing_args=['course_entitlement'])

View File

@@ -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)

View File

@@ -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])

View File

@@ -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

View File

@@ -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

View File

@@ -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