Added Python API and renamed rest_api folder

[MICROBA-281]
This commit is contained in:
Albert (AJ) St. Aubin
2020-06-01 16:06:20 -04:00
parent cdc1c91980
commit e91c837173
15 changed files with 105 additions and 52 deletions

View 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')),
]

View 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')

View 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")

View 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'
)

View File

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

View 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

View 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'
)
]

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