diff --git a/common/djangoapps/entitlements/api/v1/serializers.py b/common/djangoapps/entitlements/api/v1/serializers.py index d1c2849142..156e7ad962 100644 --- a/common/djangoapps/entitlements/api/v1/serializers.py +++ b/common/djangoapps/entitlements/api/v1/serializers.py @@ -35,6 +35,7 @@ class CourseEntitlementSerializer(serializers.ModelSerializer): 'created', 'modified', 'mode', + 'refund_locked', 'order_number', 'support_details' ) diff --git a/common/djangoapps/entitlements/api/v1/tests/test_serializers.py b/common/djangoapps/entitlements/api/v1/tests/test_serializers.py index 1073bd4c9c..b08feaf618 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_serializers.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_serializers.py @@ -27,6 +27,7 @@ class EntitlementsSerializerTests(ModuleStoreTestCase): 'expired_at': entitlement.expired_at, 'course_uuid': str(entitlement.course_uuid), 'mode': entitlement.mode, + 'refund_locked': False, 'enrollment_course_run': None, 'order_number': entitlement.order_number, 'created': entitlement.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py index 09d8b9ca2a..4423f93334 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -17,6 +17,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from student.models import CourseEnrollment @@ -27,7 +28,7 @@ log = logging.getLogger(__name__) # 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 entitlements.models import CourseEntitlement, CourseEntitlementPolicy + from entitlements.models import CourseEntitlement, CourseEntitlementPolicy, CourseEntitlementSupportDetail from entitlements.api.v1.serializers import CourseEntitlementSerializer from entitlements.api.v1.views import set_entitlement_policy @@ -606,7 +607,7 @@ class EntitlementViewSetTest(ModuleStoreTestCase): 'support_details': [ { 'unenrolled_run': str(enrollment.course.id), - 'action': 'REISSUE', + 'action': CourseEntitlementSupportDetail.REISSUE, 'comments': 'Severe illness.' } ] @@ -625,6 +626,74 @@ class EntitlementViewSetTest(ModuleStoreTestCase): ) assert results == CourseEntitlementSerializer(reinstated_entitlement).data + def test_reinstate_refundable_entitlement(self): + """ Verify that an entitlement that is refundable stays refundable when support reinstates it. """ + enrollment = CourseEnrollmentFactory(user=self.user, is_active=True, course=CourseOverviewFactory(start=now())) + fulfilled_entitlement = CourseEntitlementFactory.create( + user=self.user, enrollment_course_run=enrollment + ) + assert fulfilled_entitlement.is_entitlement_refundable() is True + url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(fulfilled_entitlement.uuid)]) + + update_data = { + 'expired_at': None, + 'enrollment_course_run': None, + 'support_details': [ + { + 'unenrolled_run': str(enrollment.course.id), + 'action': CourseEntitlementSupportDetail.REISSUE, + 'comments': 'Severe illness.' + } + ] + } + + response = self.client.patch( + url, + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 200 + + reinstated_entitlement = CourseEntitlement.objects.get( + uuid=fulfilled_entitlement.uuid + ) + assert reinstated_entitlement.refund_locked is False + assert reinstated_entitlement.is_entitlement_refundable() is True + + def test_reinstate_unrefundable_entitlement(self): + """ Verify that a no longer refundable entitlement does not become refundable when support reinstates it. """ + enrollment = CourseEnrollmentFactory(user=self.user, is_active=True) + expired_entitlement = CourseEntitlementFactory.create( + user=self.user, enrollment_course_run=enrollment, expired_at=datetime.now() + ) + assert expired_entitlement.is_entitlement_refundable() is False + url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(expired_entitlement.uuid)]) + + update_data = { + 'expired_at': None, + 'enrollment_course_run': None, + 'support_details': [ + { + 'unenrolled_run': str(enrollment.course.id), + 'action': CourseEntitlementSupportDetail.REISSUE, + 'comments': 'Severe illness.' + } + ] + } + + response = self.client.patch( + url, + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 200 + + reinstated_entitlement = CourseEntitlement.objects.get( + uuid=expired_entitlement.uuid + ) + assert reinstated_entitlement.refund_locked is True + assert reinstated_entitlement.is_entitlement_refundable() is False + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): diff --git a/common/djangoapps/entitlements/api/v1/views.py b/common/djangoapps/entitlements/api/v1/views.py index a2f56dfeb2..daac72e874 100644 --- a/common/djangoapps/entitlements/api/v1/views.py +++ b/common/djangoapps/entitlements/api/v1/views.py @@ -275,6 +275,11 @@ class EntitlementViewSet(viewsets.ModelViewSet): ) support_details = request.data.pop('support_details', []) + # If a patch request does not explicitly update an entitlement's refundability status, we want to ensure that + # changes made to other attributes of the entitlement do not implicitly change its ability to be refunded. + if request.data.get('refund_locked') is None: + request.data['refund_locked'] = not entitlement.is_entitlement_refundable() + for support_detail in support_details: support_detail['entitlement'] = entitlement support_detail['support_user'] = request.user diff --git a/common/djangoapps/entitlements/migrations/0009_courseentitlement_refund_locked.py b/common/djangoapps/entitlements/migrations/0009_courseentitlement_refund_locked.py new file mode 100644 index 0000000000..e01c112c08 --- /dev/null +++ b/common/djangoapps/entitlements/migrations/0009_courseentitlement_refund_locked.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-12 12:00 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('entitlements', '0008_auto_20180328_1107'), + ] + + operations = [ + migrations.AddField( + model_name='courseentitlement', + name='refund_locked', + field=models.BooleanField(default=False), + ), + ] diff --git a/common/djangoapps/entitlements/migrations/0010_backfill_refund_lock.py b/common/djangoapps/entitlements/migrations/0010_backfill_refund_lock.py new file mode 100644 index 0000000000..9b679ba489 --- /dev/null +++ b/common/djangoapps/entitlements/migrations/0010_backfill_refund_lock.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from datetime import datetime + +from django.db import migrations, models + + +def backfill_refundability(apps, schema_editor): + CourseEntitlementSupportDetail = apps.get_model('entitlements', 'CourseEntitlementSupportDetail') + for support_detail in CourseEntitlementSupportDetail.objects.all().select_related('entitlement'): + support_detail.entitlement.refund_locked = True + support_detail.entitlement.save() + + +def revert_backfill(apps, schema_editor): + CourseEntitlementSupportDetail = apps.get_model('entitlements', 'CourseEntitlementSupportDetail') + for support_detail in CourseEntitlementSupportDetail.objects.all().select_related('entitlement'): + support_detail.entitlement.refund_locked = False + support_detail.entitlement.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('entitlements', '0009_courseentitlement_refund_locked'), + ] + + operations = [ + migrations.RunPython(backfill_refundability, revert_backfill), + ] diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index a3acc56abc..9705b70153 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -162,6 +162,7 @@ class CourseEntitlement(TimeStampedModel): blank=True ) order_number = models.CharField(max_length=128, null=True) + refund_locked = models.BooleanField(default=False) _policy = models.ForeignKey(CourseEntitlementPolicy, null=True, blank=True) @property @@ -226,7 +227,7 @@ class CourseEntitlement(TimeStampedModel): """ Returns a boolean as to whether or not the entitlement can be refunded based on the entitlement's policy """ - return self.policy.is_entitlement_refundable(self) + return not self.refund_locked and self.policy.is_entitlement_refundable(self) def is_entitlement_redeemable(self): """ diff --git a/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js b/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js index 39dfd0c4fb..42160555f2 100644 --- a/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js +++ b/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js @@ -25,6 +25,7 @@ const postEntitlement = ({ username, courseUuid, mode, action, comments = null } course_uuid: courseUuid, user: username, mode, + refund_locked: true, support_details: [{ action, comments,