Unverified Commit 8c205ad8 by Albert (AJ) St. Aubin Committed by GitHub

Merge pull request #16877 from edx/aj/LEARNER-2668_api_changes

Entitlement API Changes to support User Revoke and Refund
parents 67752c39 1d744322
......@@ -22,6 +22,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')
......@@ -429,3 +430,48 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert response.status_code == 400
assert response.data['message'] == expected_message # pylint: disable=no-member
assert not CourseEnrollment.is_enrolled(self.user, fake_course_key)
@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
url = reverse(
self.ENTITLEMENTS_ENROLLMENT_NAMESPACE,
args=[str(course_entitlement.uuid)]
)
assert course_entitlement.enrollment_course_run is None
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
with patch('lms.djangoapps.commerce.signals.handle_refund_entitlement') as mock_refund_handler:
REFUND_ENTITLEMENT.connect(mock_refund_handler)
# 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
......@@ -14,6 +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 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
......@@ -149,7 +150,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
except AlreadyEnrolledError:
enrollment = CourseEnrollment.get_enrollment(user, course_run_key)
if enrollment.mode == entitlement.mode:
CourseEntitlement.set_enrollment(entitlement, enrollment)
entitlement.set_enrollment(enrollment)
# Else the User is already enrolled in another Mode and we should
# not do anything else related to Entitlements.
except CourseEnrollmentException:
......@@ -167,7 +168,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
data={'message': message}
)
CourseEntitlement.set_enrollment(entitlement, enrollment)
entitlement.set_enrollment(enrollment)
return None
def _unenroll_entitlement(self, entitlement, course_run_key, user):
......@@ -175,7 +176,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
Internal method to handle the details of Unenrolling a User in a Course Run.
"""
CourseEnrollment.unenroll(user, course_run_key, skip_refund=True)
CourseEntitlement.set_enrollment(entitlement, None)
entitlement.set_enrollment(None)
def create(self, request, uuid):
"""
......@@ -196,7 +197,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
data='The Course Run ID was not provided.'
)
# Verify that the user has an Entitlement for the provided Course UUID.
# Verify that the user has an Entitlement for the provided Entitlement UUID.
try:
entitlement = CourseEntitlement.objects.get(uuid=uuid, user=request.user, expired_at=None)
except CourseEntitlement.DoesNotExist:
......@@ -205,7 +206,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
data='The Entitlement for this UUID does not exist or is Expired.'
)
# Verify the course run ID is of the same type as the Course entitlement.
# Verify the course run ID is of the same Course as the Course entitlement.
course_run_valid = self._verify_course_run_for_entitlement(entitlement, course_run_id)
if not course_run_valid:
return Response(
......@@ -257,7 +258,13 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
def destroy(self, request, uuid):
"""
On DELETE call to this API we will unenroll the course enrollment for the provided uuid
If is_refund parameter is provided then unenroll the user, set Entitlement expiration, and issue
a refund
"""
is_refund = True if request.query_params.get('is_refund', 'false') == 'true' else False
# Retrieve the entitlement for the UUID belongs to the current user.
try:
entitlement = CourseEntitlement.objects.get(uuid=uuid, user=request.user, expired_at=None)
except CourseEntitlement.DoesNotExist:
......@@ -266,12 +273,42 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
data='The Entitlement for this UUID does not exist or is Expired.'
)
if entitlement.enrollment_course_run is None:
return Response(status=status.HTTP_204_NO_CONTENT)
if is_refund and entitlement.is_entitlement_refundable():
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
)
# 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:
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',
str(entitlement.uuid)
)
self._unenroll_entitlement(
entitlement=entitlement,
course_run_key=entitlement.enrollment_course_run.course_id,
user=request.user
)
return Response(status=status.HTTP_204_NO_CONTENT)
......@@ -221,12 +221,12 @@ class CourseEntitlement(TimeStampedModel):
'expired_at': self.expired_at
}
@classmethod
def set_enrollment(cls, entitlement, enrollment):
def set_enrollment(self, enrollment):
"""
Fulfills an entitlement by specifying a session.
"""
cls.objects.filter(id=entitlement.id).update(enrollment_course_run=enrollment)
self.enrollment_course_run = enrollment
self.save()
@classmethod
def unexpired_entitlements_for_user(cls, user):
......
"""
Enrollment track related signals.
"""
from django.dispatch import Signal
REFUND_ENTITLEMENT = Signal(providing_args=['course_entitlement'])
......@@ -19,13 +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 ..signals import create_zendesk_ticket, generate_refund_notification_body, send_refund_notification
from .mocks import mock_create_refund, mock_process_refund
ZENDESK_URL = 'http://zendesk.example.com/'
ZENDESK_USER = 'test@example.com'
......@@ -320,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])
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment