Added Python API and renamed rest_api folder
[MICROBA-281]
This commit is contained in:
0
common/djangoapps/entitlements/rest_api/__init__.py
Normal file
0
common/djangoapps/entitlements/rest_api/__init__.py
Normal file
10
common/djangoapps/entitlements/rest_api/urls.py
Normal file
10
common/djangoapps/entitlements/rest_api/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
URLs file for the Entitlements API.
|
||||
"""
|
||||
|
||||
from django.conf.urls import include, url
|
||||
|
||||
app_name = 'entitlements'
|
||||
urlpatterns = [
|
||||
url(r'^v1/', include('entitlements.rest_api.v1.urls')),
|
||||
]
|
||||
46
common/djangoapps/entitlements/rest_api/v1/filters.py
Normal file
46
common/djangoapps/entitlements/rest_api/v1/filters.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Filters for the Entitlements API.
|
||||
"""
|
||||
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from entitlements.models import CourseEntitlement
|
||||
|
||||
|
||||
class CharListFilter(filters.CharFilter):
|
||||
""" Filters a field via a comma-delimited list of values. """
|
||||
|
||||
def filter(self, qs, value):
|
||||
if value not in (None, ''):
|
||||
value = value.split(',')
|
||||
|
||||
return super(CharListFilter, self).filter(qs, value)
|
||||
|
||||
|
||||
class UUIDListFilter(CharListFilter):
|
||||
""" Filters a field via a comma-delimited list of UUIDs. """
|
||||
|
||||
def __init__(self, field_name='uuid', label=None, widget=None, method=None, lookup_expr='in', required=False,
|
||||
distinct=False, exclude=False, **kwargs):
|
||||
super(UUIDListFilter, self).__init__(
|
||||
field_name=field_name,
|
||||
label=label,
|
||||
widget=widget,
|
||||
method=method,
|
||||
lookup_expr=lookup_expr,
|
||||
required=required,
|
||||
distinct=distinct,
|
||||
exclude=exclude,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
class CourseEntitlementFilter(filters.FilterSet):
|
||||
"""Filter for CourseEntitlements"""
|
||||
|
||||
uuid = UUIDListFilter()
|
||||
user = filters.CharFilter(field_name='user__username')
|
||||
|
||||
class Meta:
|
||||
model = CourseEntitlement
|
||||
fields = ('uuid', 'user')
|
||||
22
common/djangoapps/entitlements/rest_api/v1/permissions.py
Normal file
22
common/djangoapps/entitlements/rest_api/v1/permissions.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
This module provides a custom DRF Permission class for supporting SAFE_METHODS to Authenticated Users, but
|
||||
requiring Superuser access for all other Request types on an API endpoint.
|
||||
"""
|
||||
|
||||
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
|
||||
|
||||
class IsAdminOrSupportOrAuthenticatedReadOnly(BasePermission):
|
||||
"""
|
||||
Method that will require admin or support access for all methods not
|
||||
in the SAFE_METHODS list. For example GET requests will not
|
||||
require an Admin or Support user.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if request.method in SAFE_METHODS:
|
||||
return request.user.is_authenticated
|
||||
else:
|
||||
return request.user.is_staff or has_access(request.user, "support", "global")
|
||||
63
common/djangoapps/entitlements/rest_api/v1/serializers.py
Normal file
63
common/djangoapps/entitlements/rest_api/v1/serializers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Serializers for all Course Entitlement related return objects.
|
||||
"""
|
||||
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
from entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
|
||||
from openedx.core.lib.api.serializers import CourseKeyField
|
||||
|
||||
|
||||
class CourseEntitlementSerializer(serializers.ModelSerializer):
|
||||
""" Serialize a learner's course entitlement and related information. """
|
||||
user = serializers.SlugRelatedField(slug_field='username', queryset=get_user_model().objects.all())
|
||||
enrollment_course_run = serializers.CharField(
|
||||
source='enrollment_course_run.course_id',
|
||||
read_only=True
|
||||
)
|
||||
support_details = serializers.SerializerMethodField()
|
||||
|
||||
def get_support_details(self, model):
|
||||
"""
|
||||
Returns a serialized set of all support interactions with the course entitlement
|
||||
"""
|
||||
qset = CourseEntitlementSupportDetail.objects.filter(entitlement=model).order_by('-created')
|
||||
return CourseEntitlementSupportDetailSerializer(qset, many=True).data
|
||||
|
||||
class Meta:
|
||||
model = CourseEntitlement
|
||||
fields = (
|
||||
'user',
|
||||
'uuid',
|
||||
'course_uuid',
|
||||
'enrollment_course_run',
|
||||
'expired_at',
|
||||
'created',
|
||||
'modified',
|
||||
'mode',
|
||||
'refund_locked',
|
||||
'order_number',
|
||||
'support_details'
|
||||
)
|
||||
|
||||
|
||||
class CourseEntitlementSupportDetailSerializer(serializers.ModelSerializer):
|
||||
""" Serialize the details of a support team interaction with a learner's course entitlement. """
|
||||
support_user = serializers.SlugRelatedField(
|
||||
read_only=True,
|
||||
slug_field='username',
|
||||
default=serializers.CurrentUserDefault()
|
||||
)
|
||||
unenrolled_run = CourseKeyField('unenrolled_run.id')
|
||||
|
||||
class Meta:
|
||||
model = CourseEntitlementSupportDetail
|
||||
fields = (
|
||||
'support_user',
|
||||
'action',
|
||||
'comments',
|
||||
'unenrolled_run',
|
||||
'created'
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Tests for the API Serializers.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
# Entitlements is not in CMS' INSTALLED_APPS so these imports will error during test collection
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
from entitlements.rest_api.v1.serializers import CourseEntitlementSerializer
|
||||
from entitlements.tests.factories import CourseEntitlementFactory
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class EntitlementsSerializerTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the Entitlement Serializers.
|
||||
"""
|
||||
|
||||
def test_data(self):
|
||||
entitlement = CourseEntitlementFactory()
|
||||
request = RequestFactory().get('')
|
||||
serializer = CourseEntitlementSerializer(entitlement, context={'request': request})
|
||||
|
||||
expected = {
|
||||
'user': entitlement.user.username,
|
||||
'uuid': str(entitlement.uuid),
|
||||
'expired_at': entitlement.expired_at,
|
||||
'course_uuid': str(entitlement.course_uuid),
|
||||
'mode': entitlement.mode,
|
||||
'refund_locked': False,
|
||||
'order_number': entitlement.order_number,
|
||||
'created': entitlement.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
'modified': entitlement.modified.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
'support_details': [],
|
||||
}
|
||||
|
||||
assert serializer.data == expected
|
||||
1152
common/djangoapps/entitlements/rest_api/v1/tests/test_views.py
Normal file
1152
common/djangoapps/entitlements/rest_api/v1/tests/test_views.py
Normal file
@@ -0,0 +1,1152 @@
|
||||
"""
|
||||
Test file to test the Entitlement API Views.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from mock import patch
|
||||
from opaque_keys.edx.locator import CourseKey
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from lms.djangoapps.courseware.models import DynamicUpgradeDeadlineConfiguration
|
||||
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 openedx.core.djangoapps.user_api.models import UserOrgTag
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
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, CourseEntitlementSupportDetail
|
||||
from entitlements.rest_api.v1.serializers import CourseEntitlementSerializer
|
||||
from entitlements.rest_api.v1.views import set_entitlement_policy
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class EntitlementViewSetTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the Entitlements API Views.
|
||||
"""
|
||||
ENTITLEMENTS_DETAILS_PATH = 'entitlements_api:v1:entitlements-detail'
|
||||
|
||||
def setUp(self):
|
||||
super(EntitlementViewSetTest, self).setUp()
|
||||
self.user = UserFactory(is_staff=True)
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
self.course = CourseFactory()
|
||||
self.course_mode = CourseModeFactory(
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
mode_slug=CourseMode.VERIFIED,
|
||||
# This must be in the future to ensure it is returned by downstream code.
|
||||
expiration_datetime=now() + timedelta(days=1)
|
||||
)
|
||||
|
||||
self.entitlements_list_url = reverse('entitlements_api:v1:entitlements-list')
|
||||
|
||||
def _get_data_set(self, user, course_uuid):
|
||||
"""
|
||||
Get a basic data set for an entitlement
|
||||
"""
|
||||
return {
|
||||
"user": user.username,
|
||||
"mode": CourseMode.VERIFIED,
|
||||
"course_uuid": course_uuid,
|
||||
"order_number": "EDX-1001",
|
||||
}
|
||||
|
||||
def _assert_default_policy(self, policy):
|
||||
"""
|
||||
Assert that a policy is equal to the default Course Entitlement Policy.
|
||||
"""
|
||||
default_policy = CourseEntitlementPolicy()
|
||||
assert policy.expiration_period == default_policy.expiration_period
|
||||
assert policy.refund_period == default_policy.refund_period
|
||||
assert policy.regain_period == default_policy.regain_period
|
||||
assert policy.mode == default_policy.mode
|
||||
|
||||
def test_auth_required(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(self.entitlements_list_url)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_staff_user_not_required_for_get(self):
|
||||
not_staff_user = UserFactory()
|
||||
self.client.login(username=not_staff_user.username, password=TEST_PASSWORD)
|
||||
response = self.client.get(self.entitlements_list_url)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_add_entitlement_with_missing_data(self):
|
||||
entitlement_data_missing_parts = self._get_data_set(self.user, str(uuid.uuid4()))
|
||||
entitlement_data_missing_parts.pop('mode')
|
||||
entitlement_data_missing_parts.pop('course_uuid')
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data_missing_parts),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_staff_user_required_for_post(self):
|
||||
not_staff_user = UserFactory()
|
||||
self.client.login(username=not_staff_user.username, password=TEST_PASSWORD)
|
||||
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_staff_user_required_for_delete(self):
|
||||
not_staff_user = UserFactory()
|
||||
self.client.login(username=not_staff_user.username, password=TEST_PASSWORD)
|
||||
|
||||
course_entitlement = CourseEntitlementFactory.create()
|
||||
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)])
|
||||
|
||||
response = self.client.delete(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_add_entitlement(self):
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
results = response.data
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
assert results == CourseEntitlementSerializer(course_entitlement).data
|
||||
|
||||
def test_add_duplicate_entitlement(self):
|
||||
"""
|
||||
Request with identical course_uuid and order_number should not create duplicate
|
||||
entitlement
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
course_entitlement = CourseEntitlement.objects.filter(
|
||||
course_uuid=course_uuid,
|
||||
order_number=entitlement_data['order_number']
|
||||
)
|
||||
assert course_entitlement.count() == 1
|
||||
|
||||
def test_order_number_null(self):
|
||||
"""
|
||||
Test that for same course_uuid order_number set to null is treated as unique
|
||||
entitlement
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['order_number'] = None
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
course_entitlement = CourseEntitlement.objects.filter(
|
||||
course_uuid=course_uuid,
|
||||
order_number=entitlement_data['order_number']
|
||||
)
|
||||
assert course_entitlement.count() == 2
|
||||
|
||||
def test_default_no_policy_entry(self):
|
||||
"""
|
||||
Verify that, when there are no entries in the course entitlement policy table,
|
||||
the default policy is used for a newly created entitlement.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
self._assert_default_policy(course_entitlement.policy)
|
||||
|
||||
def test_default_no_matching_policy_entry(self):
|
||||
"""
|
||||
Verify that, when no course entitlement policy is found with the same mode or site
|
||||
as the created entitlement, the default policy is used for the entitlement.
|
||||
"""
|
||||
CourseEntitlementPolicy.objects.create(mode=CourseMode.PROFESSIONAL, site=None)
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
self._assert_default_policy(course_entitlement.policy)
|
||||
|
||||
def test_set_custom_mode_policy_on_create(self):
|
||||
"""
|
||||
Verify that, when there does not exist a course entitlement policy with the same mode and site as
|
||||
a created entitlement, but there does exist a policy with the same mode and a null site,
|
||||
that policy is assigned to the entitlement.
|
||||
"""
|
||||
policy = CourseEntitlementPolicy.objects.create(mode=CourseMode.PROFESSIONAL, site=None)
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['mode'] = CourseMode.PROFESSIONAL
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
assert course_entitlement.policy == policy
|
||||
|
||||
# To verify policy selecting behavior involving site specificity, we interact directly
|
||||
# with the 'set_entitlement_policy' method due to an inablity to predict or manually assign
|
||||
# the site associated with the requests made in unittests.
|
||||
def test_set_custom_site_policy_on_create(self):
|
||||
"""
|
||||
Verify that, when there does not exist a course entitlement policy with the same mode and site as
|
||||
a created entitlement, but there does exist a policy with the same site and a null mode,
|
||||
that policy is assigned to the entitlement.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
|
||||
policy_site = SiteFactory.create()
|
||||
policy = CourseEntitlementPolicy.objects.create(mode=None, site=policy_site)
|
||||
|
||||
set_entitlement_policy(course_entitlement, policy_site)
|
||||
assert course_entitlement.policy == policy
|
||||
|
||||
def test_set_policy_match_site_over_mode(self):
|
||||
"""
|
||||
Verify that, when both a mode-agnostic policy matching the site of a created entitlement and a site-agnostic
|
||||
policy matching the mode of a created entitlement exist but no policy matching both the site and mode of the
|
||||
created entitlement exists, the site-specific (mode-agnostic) policy matching the entitlement is selected over
|
||||
the mode-specific (site-agnostic) policy.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
|
||||
policy_site = SiteFactory.create()
|
||||
policy = CourseEntitlementPolicy.objects.create(mode=None, site=policy_site)
|
||||
CourseEntitlementPolicy.objects.create(mode=entitlement_data['mode'], site=None)
|
||||
|
||||
set_entitlement_policy(course_entitlement, policy_site)
|
||||
assert course_entitlement.policy == policy
|
||||
|
||||
def test_set_policy_site_and_mode_specific(self):
|
||||
"""
|
||||
Verify that, when there exists a policy matching both the mode and site of the a given course entitlement,
|
||||
it is selected over appropriate site- and mode-specific (mode- and site-agnostic) policies and the default
|
||||
policy for assignment to the entitlement.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['mode'] = CourseMode.PROFESSIONAL
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
|
||||
policy_site = SiteFactory.create()
|
||||
policy = CourseEntitlementPolicy.objects.create(mode=entitlement_data['mode'], site=policy_site)
|
||||
CourseEntitlementPolicy.objects.create(mode=entitlement_data['mode'], site=None)
|
||||
CourseEntitlementPolicy.objects.create(mode=None, site=policy_site)
|
||||
|
||||
set_entitlement_policy(course_entitlement, policy_site)
|
||||
assert course_entitlement.policy == policy
|
||||
|
||||
def test_professional_policy_for_no_id_professional(self):
|
||||
"""
|
||||
Verify that when there exists a policy with a professional mode that it is assigned
|
||||
to new entitlements with the mode no-id-professional.
|
||||
"""
|
||||
policy = CourseEntitlementPolicy.objects.create(mode=CourseMode.PROFESSIONAL)
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['mode'] = CourseMode.NO_ID_PROFESSIONAL_MODE
|
||||
|
||||
self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
assert course_entitlement.policy == policy
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_owners_for_course")
|
||||
def test_email_opt_in_single_org(self, mock_get_owners):
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['email_opt_in'] = True
|
||||
|
||||
org = u'particularly'
|
||||
mock_get_owners.return_value = [{'key': org}]
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
result_obj = UserOrgTag.objects.get(user=self.user, org=org, key='email-optin')
|
||||
self.assertEqual(result_obj.value, u"True")
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_owners_for_course")
|
||||
def test_email_opt_in_multiple_orgs(self, mock_get_owners):
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['email_opt_in'] = True
|
||||
|
||||
org_1 = u'particularly'
|
||||
org_2 = u'underwood'
|
||||
mock_get_owners.return_value = [{'key': org_1}, {'key': org_2}]
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
result_obj = UserOrgTag.objects.get(user=self.user, org=org_1, key='email-optin')
|
||||
self.assertEqual(result_obj.value, u"True")
|
||||
result_obj = UserOrgTag.objects.get(user=self.user, org=org_2, key='email-optin')
|
||||
self.assertEqual(result_obj.value, u"True")
|
||||
|
||||
def test_add_entitlement_with_support_detail(self):
|
||||
"""
|
||||
Verify that an EntitlementSupportDetail entry is made when the request includes support interaction information.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
entitlement_data['support_details'] = [
|
||||
{
|
||||
"action": "CREATE",
|
||||
"comments": "Family emergency."
|
||||
},
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
results = response.data
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
assert results == CourseEntitlementSerializer(course_entitlement).data
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_add_entitlement_and_upgrade_audit_enrollment(self, mock_get_course_runs):
|
||||
"""
|
||||
Verify that if an entitlement is added for a user, if the user has one upgradeable enrollment
|
||||
that enrollment is upgraded to the mode of the entitlement and linked to the entitlement.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
mock_get_course_runs.return_value = [{'key': str(self.course.id)}] # pylint: disable=no-member
|
||||
|
||||
# Add an audit course enrollment for user.
|
||||
enrollment = CourseEnrollment.enroll(
|
||||
self.user,
|
||||
self.course.id, # pylint: disable=no-member
|
||||
mode=CourseMode.AUDIT)
|
||||
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
results = response.data
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
# Assert that enrollment mode is now verified
|
||||
enrollment_mode = CourseEnrollment.enrollment_mode_for_user(
|
||||
self.user,
|
||||
self.course.id # pylint: disable=no-member
|
||||
)[0]
|
||||
assert enrollment_mode == course_entitlement.mode
|
||||
assert course_entitlement.enrollment_course_run == enrollment
|
||||
assert results == CourseEntitlementSerializer(course_entitlement).data
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_add_entitlement_and_upgrade_audit_enrollment_with_dynamic_deadline(self, mock_get_course_runs):
|
||||
"""
|
||||
Verify that if an entitlement is added for a user, if the user has one upgradeable enrollment
|
||||
that enrollment is upgraded to the mode of the entitlement and linked to the entitlement regardless of
|
||||
dynamic upgrade deadline being set.
|
||||
"""
|
||||
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
|
||||
course = CourseFactory.create(self_paced=True)
|
||||
course_uuid = uuid.uuid4()
|
||||
course_mode = CourseModeFactory(
|
||||
course_id=course.id,
|
||||
mode_slug=CourseMode.VERIFIED,
|
||||
# This must be in the future to ensure it is returned by downstream code.
|
||||
expiration_datetime=now() + timedelta(days=1)
|
||||
)
|
||||
|
||||
# Set up Entitlement
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
mock_get_course_runs.return_value = [{'key': str(course.id)}]
|
||||
|
||||
# Add an audit course enrollment for user.
|
||||
enrollment = CourseEnrollment.enroll(self.user, course.id, mode=CourseMode.AUDIT)
|
||||
|
||||
# Set an upgrade schedule so that dynamic upgrade deadlines are used
|
||||
ScheduleFactory.create(
|
||||
enrollment=enrollment,
|
||||
upgrade_deadline=course_mode.expiration_datetime + timedelta(days=-3)
|
||||
)
|
||||
|
||||
# The upgrade should complete and ignore the deadline
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
results = response.data
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
# Assert that enrollment mode is now verified
|
||||
enrollment_mode = CourseEnrollment.enrollment_mode_for_user(self.user, course.id)[0]
|
||||
assert enrollment_mode == course_entitlement.mode
|
||||
assert course_entitlement.enrollment_course_run == enrollment
|
||||
assert results == CourseEntitlementSerializer(course_entitlement).data
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_add_entitlement_inactive_audit_enrollment(self, mock_get_course_runs):
|
||||
"""
|
||||
Verify that if an entitlement is added for a user, if the user has an inactive audit enrollment
|
||||
that enrollment is NOT upgraded to the mode of the entitlement and linked to the entitlement.
|
||||
"""
|
||||
course_uuid = uuid.uuid4()
|
||||
entitlement_data = self._get_data_set(self.user, str(course_uuid))
|
||||
mock_get_course_runs.return_value = [{'key': str(self.course.id)}] # pylint: disable=no-member
|
||||
|
||||
# Add an audit course enrollment for user.
|
||||
enrollment = CourseEnrollment.enroll(
|
||||
self.user,
|
||||
self.course.id, # pylint: disable=no-member
|
||||
mode=CourseMode.AUDIT
|
||||
)
|
||||
enrollment.update_enrollment(is_active=False)
|
||||
response = self.client.post(
|
||||
self.entitlements_list_url,
|
||||
data=json.dumps(entitlement_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
results = response.data
|
||||
|
||||
course_entitlement = CourseEntitlement.objects.get(
|
||||
user=self.user,
|
||||
course_uuid=course_uuid
|
||||
)
|
||||
# Assert that enrollment mode is now verified
|
||||
enrollment_mode, enrollment_active = CourseEnrollment.enrollment_mode_for_user(
|
||||
self.user,
|
||||
self.course.id # pylint: disable=no-member
|
||||
)
|
||||
assert enrollment_mode == CourseMode.AUDIT
|
||||
assert enrollment_active is False
|
||||
assert course_entitlement.enrollment_course_run is None
|
||||
assert results == CourseEntitlementSerializer(course_entitlement).data
|
||||
|
||||
def test_non_staff_get_select_entitlements(self):
|
||||
not_staff_user = UserFactory()
|
||||
self.client.login(username=not_staff_user.username, password=TEST_PASSWORD)
|
||||
CourseEntitlementFactory.create_batch(2)
|
||||
entitlement = CourseEntitlementFactory.create(user=not_staff_user)
|
||||
response = self.client.get(
|
||||
self.entitlements_list_url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.data.get('results', [])
|
||||
assert results == CourseEntitlementSerializer([entitlement], many=True).data
|
||||
|
||||
def test_staff_get_only_staff_entitlements(self):
|
||||
CourseEntitlementFactory.create_batch(2)
|
||||
entitlement = CourseEntitlementFactory.create(user=self.user)
|
||||
|
||||
response = self.client.get(
|
||||
self.entitlements_list_url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.data.get('results', [])
|
||||
assert results == CourseEntitlementSerializer([entitlement], many=True).data
|
||||
|
||||
def test_staff_get_expired_entitlements(self):
|
||||
past_datetime = now() - timedelta(days=365 * 2)
|
||||
entitlements = CourseEntitlementFactory.create_batch(2, created=past_datetime, user=self.user)
|
||||
|
||||
# Set the first entitlement to be at a time that it isn't expired
|
||||
entitlements[0].created = now()
|
||||
entitlements[0].save()
|
||||
|
||||
response = self.client.get(
|
||||
self.entitlements_list_url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
results = response.data.get('results', [])
|
||||
# Make sure that the first result isn't expired, and the second one is also not for staff users
|
||||
assert results[0].get('expired_at') is None and results[1].get('expired_at') is None
|
||||
|
||||
def test_get_user_expired_entitlements(self):
|
||||
past_datetime = now() - timedelta(days=365 * 2)
|
||||
not_staff_user = UserFactory()
|
||||
self.client.login(username=not_staff_user.username, password=TEST_PASSWORD)
|
||||
entitlement_user2 = CourseEntitlementFactory.create_batch(2, user=not_staff_user, created=past_datetime)
|
||||
url = reverse('entitlements_api:v1:entitlements-list')
|
||||
url += '?user={username}'.format(username=not_staff_user.username)
|
||||
|
||||
# Set the first entitlement to be at a time that it isn't expired
|
||||
entitlement_user2[0].created = now()
|
||||
entitlement_user2[0].save()
|
||||
|
||||
response = self.client.get(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.data.get('results', [])
|
||||
assert results[0].get('expired_at') is None and results[1].get('expired_at')
|
||||
|
||||
def test_get_user_entitlements(self):
|
||||
user2 = UserFactory()
|
||||
CourseEntitlementFactory.create()
|
||||
entitlement_user2 = CourseEntitlementFactory.create(user=user2)
|
||||
url = reverse('entitlements_api:v1:entitlements-list')
|
||||
url += '?user={username}'.format(username=user2.username)
|
||||
response = self.client.get(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.data.get('results', [])
|
||||
assert results == CourseEntitlementSerializer([entitlement_user2], many=True).data
|
||||
|
||||
def test_get_entitlement_by_uuid(self):
|
||||
entitlement = CourseEntitlementFactory.create()
|
||||
CourseEntitlementFactory.create_batch(2)
|
||||
|
||||
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(entitlement.uuid)])
|
||||
|
||||
response = self.client.get(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.data
|
||||
assert results == CourseEntitlementSerializer(entitlement).data and results.get('expired_at') is None
|
||||
|
||||
def test_get_expired_entitlement_by_uuid(self):
|
||||
past_datetime = now() - timedelta(days=365 * 2)
|
||||
entitlement = CourseEntitlementFactory(created=past_datetime)
|
||||
CourseEntitlementFactory.create_batch(2)
|
||||
|
||||
CourseEntitlementFactory()
|
||||
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(entitlement.uuid)])
|
||||
|
||||
response = self.client.get(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.data
|
||||
assert results.get('expired_at')
|
||||
|
||||
def test_delete_and_revoke_entitlement(self):
|
||||
course_entitlement = CourseEntitlementFactory.create()
|
||||
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)])
|
||||
|
||||
response = self.client.delete(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
course_entitlement.refresh_from_db()
|
||||
assert course_entitlement.expired_at is not None
|
||||
|
||||
@patch("entitlements.models.get_course_uuid_for_course")
|
||||
def test_revoke_unenroll_entitlement(self, mock_course_uuid):
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=self.user,
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
is_active=True
|
||||
)
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
|
||||
mock_course_uuid.return_value = course_entitlement.course_uuid
|
||||
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)])
|
||||
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
response = self.client.delete(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
course_entitlement.refresh_from_db()
|
||||
assert course_entitlement.expired_at is not None
|
||||
assert course_entitlement.enrollment_course_run is None
|
||||
|
||||
def test_reinstate_entitlement(self):
|
||||
enrollment = CourseEnrollmentFactory(user=self.user, is_active=True)
|
||||
expired_entitlement = CourseEntitlementFactory.create(
|
||||
user=self.user, enrollment_course_run=enrollment, expired_at=datetime.now()
|
||||
)
|
||||
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
|
||||
|
||||
results = response.data
|
||||
reinstated_entitlement = CourseEntitlement.objects.get(
|
||||
uuid=expired_entitlement.uuid
|
||||
)
|
||||
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):
|
||||
"""
|
||||
Tests for the EntitlementEnrollmentViewSets
|
||||
"""
|
||||
ENTITLEMENTS_ENROLLMENT_NAMESPACE = 'entitlements_api:v1:enrollments'
|
||||
|
||||
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')
|
||||
|
||||
self.course_mode = CourseModeFactory(
|
||||
course_id=self.course.id,
|
||||
mode_slug=CourseMode.VERIFIED,
|
||||
# This must be in the future to ensure it is returned by downstream code.
|
||||
expiration_datetime=now() + timedelta(days=1)
|
||||
)
|
||||
|
||||
self.course_mode = CourseModeFactory(
|
||||
course_id=self.course2.id,
|
||||
mode_slug=CourseMode.VERIFIED,
|
||||
# This must be in the future to ensure it is returned by downstream code.
|
||||
expiration_datetime=now() + timedelta(days=1)
|
||||
)
|
||||
|
||||
self.return_values = [
|
||||
{'key': str(self.course.id)},
|
||||
{'key': str(self.course2.id)}
|
||||
]
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_user_can_enroll(self, mock_get_course_runs):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
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)
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
@patch("entitlements.models.get_course_uuid_for_course")
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_user_can_unenroll(self, mock_get_course_runs, mock_get_course_uuid):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
mock_get_course_runs.return_value = self.return_values
|
||||
mock_get_course_uuid.return_value = course_entitlement.course_uuid
|
||||
|
||||
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)
|
||||
|
||||
response = self.client.delete(
|
||||
url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
course_entitlement.refresh_from_db()
|
||||
assert not CourseEnrollment.is_enrolled(self.user, self.course.id)
|
||||
assert course_entitlement.enrollment_course_run is None
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_user_can_switch(self, mock_get_course_runs):
|
||||
mock_get_course_runs.return_value = self.return_values
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
|
||||
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)
|
||||
|
||||
data = {
|
||||
'course_run_id': str(self.course2.id)
|
||||
}
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
course_entitlement.refresh_from_db()
|
||||
assert CourseEnrollment.is_enrolled(self.user, self.course2.id)
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_user_already_enrolled(self, mock_get_course_runs):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
mock_get_course_runs.return_value = self.return_values
|
||||
|
||||
url = reverse(
|
||||
self.ENTITLEMENTS_ENROLLMENT_NAMESPACE,
|
||||
args=[str(course_entitlement.uuid)]
|
||||
)
|
||||
|
||||
CourseEnrollment.enroll(self.user, self.course.id, mode=course_entitlement.mode)
|
||||
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)
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_user_already_enrolled_in_unpaid_mode(self, mock_get_course_runs):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
mock_get_course_runs.return_value = self.return_values
|
||||
|
||||
url = reverse(
|
||||
self.ENTITLEMENTS_ENROLLMENT_NAMESPACE,
|
||||
args=[str(course_entitlement.uuid)]
|
||||
)
|
||||
|
||||
CourseEnrollment.enroll(self.user, self.course.id, mode=CourseMode.AUDIT)
|
||||
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)
|
||||
(enrolled_mode, is_active) = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
|
||||
assert is_active and (enrolled_mode == course_entitlement.mode)
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
@patch("entitlements.rest_api.v1.views.get_course_runs_for_course")
|
||||
def test_user_cannot_enroll_in_unknown_course_run_id(self, mock_get_course_runs):
|
||||
fake_course_str = str(self.course.id) + 'fake'
|
||||
fake_course_key = CourseKey.from_string(fake_course_str)
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
mock_get_course_runs.return_value = self.return_values
|
||||
|
||||
url = reverse(
|
||||
self.ENTITLEMENTS_ENROLLMENT_NAMESPACE,
|
||||
args=[str(course_entitlement.uuid)]
|
||||
)
|
||||
|
||||
data = {
|
||||
'course_run_id': str(fake_course_key)
|
||||
}
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
expected_message = 'The Course Run ID is not a match for this Course Entitlement.'
|
||||
assert response.status_code == 400
|
||||
assert response.data['message'] == expected_message
|
||||
assert not CourseEnrollment.is_enrolled(self.user, fake_course_key)
|
||||
|
||||
@patch('entitlements.models.refund_entitlement', return_value=True)
|
||||
@patch('entitlements.rest_api.v1.views.get_course_runs_for_course')
|
||||
@patch("entitlements.models.get_course_uuid_for_course")
|
||||
def test_user_can_revoke_and_refund(self, mock_course_uuid, mock_get_course_runs, mock_refund_entitlement):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
mock_get_course_runs.return_value = self.return_values
|
||||
mock_course_uuid.return_value = course_entitlement.course_uuid
|
||||
|
||||
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
|
||||
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_entitlement.is_called
|
||||
assert mock_refund_entitlement.call_args[1]['course_entitlement'] == course_entitlement
|
||||
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.rest_api.v1.views.CourseEntitlement.is_entitlement_refundable', return_value=False)
|
||||
@patch('entitlements.models.refund_entitlement', return_value=True)
|
||||
@patch('entitlements.rest_api.v1.views.get_course_runs_for_course')
|
||||
def test_user_can_revoke_and_no_refund_available(
|
||||
self,
|
||||
mock_get_course_runs,
|
||||
mock_refund_entitlement, # pylint: disable=unused-argument
|
||||
mock_is_refundable # pylint: disable=unused-argument
|
||||
):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
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
|
||||
revoke_url = url + '?is_refund=true'
|
||||
response = self.client.delete(
|
||||
revoke_url,
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
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
|
||||
|
||||
@patch('entitlements.rest_api.v1.views.CourseEntitlement.is_entitlement_refundable', return_value=True)
|
||||
@patch('entitlements.models.refund_entitlement', return_value=False)
|
||||
@patch("entitlements.rest_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, # pylint: disable=unused-argument
|
||||
mock_is_refundable # pylint: disable=unused-argument
|
||||
):
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED)
|
||||
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
|
||||
26
common/djangoapps/entitlements/rest_api/v1/urls.py
Normal file
26
common/djangoapps/entitlements/rest_api/v1/urls.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
URLs for the V1 of the Entitlements API.
|
||||
"""
|
||||
|
||||
from django.conf.urls import include, url
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import EntitlementEnrollmentViewSet, EntitlementViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'entitlements', EntitlementViewSet, basename='entitlements')
|
||||
|
||||
ENROLLMENTS_VIEW = EntitlementEnrollmentViewSet.as_view({
|
||||
'post': 'create',
|
||||
'delete': 'destroy',
|
||||
})
|
||||
|
||||
app_name = 'v1'
|
||||
urlpatterns = [
|
||||
url(r'', include(router.urls)),
|
||||
url(
|
||||
r'entitlements/(?P<uuid>{regex})/enrollments$'.format(regex=EntitlementViewSet.ENTITLEMENT_UUID4_REGEX),
|
||||
ENROLLMENTS_VIEW,
|
||||
name='enrollments'
|
||||
)
|
||||
]
|
||||
514
common/djangoapps/entitlements/rest_api/v1/views.py
Normal file
514
common/djangoapps/entitlements/rest_api/v1/views.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Views for the Entitlements v1 API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from edx_rest_framework_extensions.paginators import DefaultPagination
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from entitlements.rest_api.v1.filters import CourseEntitlementFilter
|
||||
from entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly
|
||||
from entitlements.rest_api.v1.serializers import CourseEntitlementSerializer
|
||||
from entitlements.models import CourseEntitlement, CourseEntitlementPolicy, CourseEntitlementSupportDetail
|
||||
from entitlements.utils import is_course_run_entitlement_fulfillable
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
from student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntitlementsPagination(DefaultPagination):
|
||||
"""
|
||||
Paginator for entitlements API.
|
||||
"""
|
||||
page_size = 50
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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.expire_entitlement()
|
||||
log.info(
|
||||
'Set expired_at to [%s] for course entitlement [%s]',
|
||||
course_entitlement.expired_at,
|
||||
course_entitlement.uuid
|
||||
)
|
||||
|
||||
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:
|
||||
course_entitlement.refund()
|
||||
|
||||
|
||||
def set_entitlement_policy(entitlement, site):
|
||||
"""
|
||||
Assign the appropriate CourseEntitlementPolicy to the given CourseEntitlement based on its mode and site.
|
||||
|
||||
Arguments:
|
||||
entitlement: Course Entitlement object
|
||||
site: string representation of a Site object
|
||||
|
||||
Notes:
|
||||
Site-specific, mode-agnostic policies take precedence over mode-specific, site-agnostic policies.
|
||||
If no appropriate CourseEntitlementPolicy is found, the default CourseEntitlementPolicy is assigned.
|
||||
"""
|
||||
policy_mode = entitlement.mode
|
||||
if CourseMode.is_professional_slug(policy_mode):
|
||||
policy_mode = CourseMode.PROFESSIONAL
|
||||
filter_query = (Q(site=site) | Q(site__isnull=True)) & (Q(mode=policy_mode) | Q(mode__isnull=True))
|
||||
policy = CourseEntitlementPolicy.objects.filter(filter_query).order_by('-site', '-mode').first()
|
||||
entitlement.policy = policy if policy else None
|
||||
entitlement.save()
|
||||
|
||||
|
||||
class EntitlementViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for the Entitlements API.
|
||||
"""
|
||||
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}'
|
||||
|
||||
authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,)
|
||||
permission_classes = (permissions.IsAuthenticated, IsAdminOrSupportOrAuthenticatedReadOnly,)
|
||||
lookup_value_regex = ENTITLEMENT_UUID4_REGEX
|
||||
lookup_field = 'uuid'
|
||||
serializer_class = CourseEntitlementSerializer
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = CourseEntitlementFilter
|
||||
pagination_class = EntitlementsPagination
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
if self.request.method in permissions.SAFE_METHODS:
|
||||
if (user.is_staff and
|
||||
(self.request.query_params.get('user', None) is not None or
|
||||
self.kwargs.get('uuid', None) is not None)):
|
||||
# Return the full query set so that the Filters class can be used to apply,
|
||||
# - The UUID Filter
|
||||
# - The User Filter to the GET request
|
||||
return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run')
|
||||
# Non Staff Users will only be able to retrieve their own entitlements
|
||||
return CourseEntitlement.objects.filter(user=user).select_related('user').select_related(
|
||||
'enrollment_course_run'
|
||||
)
|
||||
# All other methods require the full Query set and the Permissions class already restricts access to them
|
||||
# to Admin users
|
||||
return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run')
|
||||
|
||||
def get_upgradeable_enrollments_for_entitlement(self, entitlement):
|
||||
"""
|
||||
Retrieve all the CourseEnrollments that are upgradeable for a given CourseEntitlement
|
||||
|
||||
Arguments:
|
||||
entitlement: CourseEntitlement that we are requesting the CourseEnrollments for.
|
||||
|
||||
Returns:
|
||||
list: List of upgradeable CourseEnrollments
|
||||
"""
|
||||
# find all course_runs within the course
|
||||
course_runs = get_course_runs_for_course(entitlement.course_uuid)
|
||||
|
||||
# check if the user has enrollments for any of the course_runs
|
||||
upgradeable_enrollments = []
|
||||
for course_run in course_runs:
|
||||
course_run_id = CourseKey.from_string(course_run.get('key'))
|
||||
enrollment = CourseEnrollment.get_enrollment(entitlement.user, course_run_id)
|
||||
|
||||
if (enrollment and
|
||||
enrollment.is_active and
|
||||
is_course_run_entitlement_fulfillable(course_run_id, entitlement)):
|
||||
upgradeable_enrollments.append(enrollment)
|
||||
|
||||
return upgradeable_enrollments
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
support_details = request.data.pop('support_details', [])
|
||||
email_opt_in = request.data.pop('email_opt_in', False)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
entitlement = serializer.instance
|
||||
set_entitlement_policy(entitlement, request.site)
|
||||
|
||||
# The owners for a course are the organizations that own the course. By taking owner.key,
|
||||
# we are able to pass in the organization key for email_opt_in
|
||||
owners = get_owners_for_course(entitlement.course_uuid)
|
||||
for owner in owners:
|
||||
update_email_opt_in(entitlement.user, owner['key'], email_opt_in)
|
||||
|
||||
if support_details:
|
||||
for support_detail in support_details:
|
||||
support_detail['entitlement'] = entitlement
|
||||
support_detail['support_user'] = request.user
|
||||
CourseEntitlementSupportDetail.objects.create(**support_detail)
|
||||
else:
|
||||
user = entitlement.user
|
||||
upgradeable_enrollments = self.get_upgradeable_enrollments_for_entitlement(entitlement)
|
||||
|
||||
# if there is only one upgradeable enrollment, update the mode to the paid entitlement.mode
|
||||
# if there is any ambiguity about which enrollment to upgrade
|
||||
# (i.e. multiple upgradeable enrollments or no available upgradeable enrollment), don't alter
|
||||
# the enrollment
|
||||
if len(upgradeable_enrollments) == 1:
|
||||
enrollment = upgradeable_enrollments[0]
|
||||
log.info(
|
||||
'Upgrading enrollment [%s] from %s to %s while adding entitlement for user [%s] for course [%s]',
|
||||
enrollment,
|
||||
enrollment.mode,
|
||||
serializer.data.get('mode'),
|
||||
user.username,
|
||||
serializer.data.get('course_uuid')
|
||||
)
|
||||
enrollment.update_enrollment(mode=entitlement.mode)
|
||||
entitlement.set_enrollment(enrollment)
|
||||
else:
|
||||
log.info(
|
||||
'No enrollment upgraded while adding entitlement for user [%s] for course [%s] ',
|
||||
user.username,
|
||||
serializer.data.get('course_uuid')
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
Override the retrieve method to expire a record that is past the
|
||||
policy and is requested via the API before returning that record.
|
||||
"""
|
||||
entitlement = self.get_object()
|
||||
entitlement.update_expired_at()
|
||||
serializer = self.get_serializer(entitlement)
|
||||
return Response(serializer.data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Override the list method to expire records that are past the
|
||||
policy and requested via the API before returning those records.
|
||||
"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if not user.is_staff:
|
||||
with transaction.atomic():
|
||||
for entitlement in queryset:
|
||||
entitlement.update_expired_at()
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
entitlement_uuid = kwargs.get('uuid', None)
|
||||
|
||||
try:
|
||||
entitlement = CourseEntitlement.objects.get(uuid=entitlement_uuid)
|
||||
except CourseEntitlement.DoesNotExist:
|
||||
return HttpResponseBadRequest(
|
||||
u'Could not find entitlement {entitlement_uuid} to update'.format(
|
||||
entitlement_uuid=entitlement_uuid
|
||||
)
|
||||
)
|
||||
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
|
||||
unenrolled_run_id = support_detail.get('unenrolled_run', None)
|
||||
if unenrolled_run_id:
|
||||
try:
|
||||
unenrolled_run_course_key = CourseKey.from_string(unenrolled_run_id)
|
||||
_unenroll_entitlement(entitlement, unenrolled_run_course_key)
|
||||
support_detail['unenrolled_run'] = CourseOverview.objects.get(id=unenrolled_run_course_key)
|
||||
except (InvalidKeyError, CourseOverview.DoesNotExist) as error:
|
||||
return HttpResponseBadRequest(
|
||||
u'Error raised while trying to unenroll user {user} from course run {course_id}: {error}'
|
||||
.format(user=entitlement.user.username, course_id=unenrolled_run_id, error=error)
|
||||
)
|
||||
CourseEntitlementSupportDetail.objects.create(**support_detail)
|
||||
|
||||
return super(EntitlementViewSet, self).partial_update(request, *args, **kwargs) # pylint: disable=no-member
|
||||
|
||||
|
||||
class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
Endpoint in the Entitlement API to handle the Enrollment of a User's Entitlement.
|
||||
This API will handle
|
||||
- Enroll
|
||||
- Unenroll
|
||||
- Switch Enrollment
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication, SessionAuthentication,)
|
||||
# TODO: ARCH-91
|
||||
# This view is excluded from Swagger doc generation because it
|
||||
# does not specify a serializer class.
|
||||
exclude_from_schema = True
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
queryset = CourseEntitlement.objects.all()
|
||||
|
||||
def _verify_course_run_for_entitlement(self, entitlement, course_run_id):
|
||||
"""
|
||||
Verifies that a Course run is a child of the Course assigned to the entitlement.
|
||||
"""
|
||||
course_runs = get_course_runs_for_course(entitlement.course_uuid)
|
||||
for run in course_runs:
|
||||
if course_run_id == run.get('key', ''):
|
||||
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.
|
||||
|
||||
Returns a response object is there is an error or exception, None otherwise
|
||||
"""
|
||||
try:
|
||||
enrollment = CourseEnrollment.enroll(
|
||||
user=user,
|
||||
course_key=course_run_key,
|
||||
mode=entitlement.mode,
|
||||
check_access=True
|
||||
)
|
||||
except AlreadyEnrolledError:
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_run_key)
|
||||
if enrollment.mode == entitlement.mode:
|
||||
entitlement.set_enrollment(enrollment)
|
||||
elif enrollment.mode not in [mode.slug for mode in CourseMode.paid_modes_for_course(course_run_key)]:
|
||||
enrollment.update_enrollment(mode=entitlement.mode)
|
||||
entitlement.set_enrollment(enrollment)
|
||||
# Else the User is already enrolled in another paid Mode and we should
|
||||
# not do anything else related to Entitlements.
|
||||
except CourseEnrollmentException:
|
||||
message = (
|
||||
'Course Entitlement Enroll for {username} failed for course: {course_id}, '
|
||||
'mode: {mode}, and entitlement: {entitlement}'
|
||||
).format(
|
||||
username=user.username,
|
||||
course_id=course_run_key,
|
||||
mode=entitlement.mode,
|
||||
entitlement=entitlement.uuid
|
||||
)
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={'message': message}
|
||||
)
|
||||
|
||||
entitlement.set_enrollment(enrollment)
|
||||
return None
|
||||
|
||||
def create(self, request, uuid):
|
||||
"""
|
||||
On POST this method will be called and will handle enrolling a user in the
|
||||
provided course_run_id from the data. This is called on a specific entitlement
|
||||
UUID so the course_run_id has to correspond to the Course that is assigned to
|
||||
the Entitlement.
|
||||
|
||||
When this API is called for a user who is already enrolled in a run that User
|
||||
will be unenrolled from their current run and enrolled in the new run if it is
|
||||
available.
|
||||
"""
|
||||
course_run_id = request.data.get('course_run_id', None)
|
||||
|
||||
if not course_run_id:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data='The Course Run ID was not provided.'
|
||||
)
|
||||
|
||||
# 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:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data='The Entitlement for this UUID does not exist or is Expired.'
|
||||
)
|
||||
|
||||
# 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(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={
|
||||
'message': 'The Course Run ID is not a match for this Course Entitlement.'
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
course_run_key = CourseKey.from_string(course_run_id)
|
||||
except InvalidKeyError:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={
|
||||
'message': 'Invalid {course_id}'.format(course_id=course_run_id)
|
||||
}
|
||||
)
|
||||
|
||||
# Verify that the run is fullfillable
|
||||
if not is_course_run_entitlement_fulfillable(course_run_key, entitlement):
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={
|
||||
'message': 'The User is unable to enroll in Course Run {course_id}, it is not available.'.format(
|
||||
course_id=course_run_id
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Determine if this is a Switch session or a simple enroll and handle both.
|
||||
if entitlement.enrollment_course_run is None:
|
||||
response = self._enroll_entitlement(
|
||||
entitlement=entitlement,
|
||||
course_run_key=course_run_key,
|
||||
user=request.user
|
||||
)
|
||||
if response:
|
||||
return response
|
||||
elif entitlement.enrollment_course_run.course_id != course_run_id:
|
||||
_unenroll_entitlement(
|
||||
course_entitlement=entitlement,
|
||||
course_run_key=entitlement.enrollment_course_run.course_id
|
||||
)
|
||||
response = self._enroll_entitlement(
|
||||
entitlement=entitlement,
|
||||
course_run_key=course_run_key,
|
||||
user=request.user
|
||||
)
|
||||
if response:
|
||||
return response
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_201_CREATED,
|
||||
data={
|
||||
'course_run_id': course_run_id,
|
||||
}
|
||||
)
|
||||
|
||||
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 = request.query_params.get('is_refund', 'false') == 'true'
|
||||
|
||||
# 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:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data='The Entitlement for this UUID does not exist or is Expired.'
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
elif not is_refund:
|
||||
if entitlement.enrollment_course_run is not None:
|
||||
_unenroll_entitlement(
|
||||
course_entitlement=entitlement,
|
||||
course_run_key=entitlement.enrollment_course_run.course_id
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
'Entitlement Refund failed for Course Entitlement [%s]. Entitlement is not refundable',
|
||||
entitlement.uuid
|
||||
)
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={
|
||||
'message': 'Entitlement refund failed, Entitlement is not refundable'
|
||||
})
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user