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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
common/djangoapps/entitlements/signals.py
Normal file
6
common/djangoapps/entitlements/signals.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Enrollment track related signals.
|
||||
"""
|
||||
from django.dispatch import Signal
|
||||
|
||||
REFUND_ENTITLEMENT = Signal(providing_args=['course_entitlement'])
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user