Add Support endpoint for Course Entitlements
This commit is contained in:
committed by
McKenzie Welter
parent
f1694661e8
commit
449c6903a6
@@ -1,10 +1,15 @@
|
||||
"""
|
||||
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
|
||||
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',
|
||||
@@ -24,3 +29,40 @@ class CourseEntitlementSerializer(serializers.ModelSerializer):
|
||||
'mode',
|
||||
'order_number'
|
||||
)
|
||||
|
||||
|
||||
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',
|
||||
'reason',
|
||||
'comments',
|
||||
'unenrolled_run'
|
||||
)
|
||||
|
||||
|
||||
class SupportCourseEntitlementSerializer(CourseEntitlementSerializer):
|
||||
"""
|
||||
Serialize a learner's course entitlement with details from all support team interactions with that entitlement.
|
||||
"""
|
||||
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 = CourseEntitlementSerializer.Meta.fields + ('support_details', )
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import uuid as uuid_tools
|
||||
from datetime import timedelta
|
||||
from util.date_utils import strftime_localized
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from model_utils.models import TimeStampedModel
|
||||
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from model_utils.models import TimeStampedModel
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment
|
||||
from util.date_utils import strftime_localized
|
||||
|
||||
|
||||
class CourseEntitlementPolicy(models.Model):
|
||||
@@ -253,6 +254,23 @@ class CourseEntitlement(TimeStampedModel):
|
||||
self.enrollment_course_run = enrollment
|
||||
self.save()
|
||||
|
||||
def reinstate(self):
|
||||
"""
|
||||
Unenrolls a user from the run in which they have spent the given entitlement and
|
||||
sets the entitlement's expired_at date to null.
|
||||
|
||||
Returns:
|
||||
CourseOverview: course run from which the user has been unenrolled
|
||||
"""
|
||||
unenrolled_run = self.enrollment_course_run.course
|
||||
self.expired_at = None
|
||||
CourseEnrollment.unenroll(
|
||||
user=self.enrollment_course_run.user, course_id=unenrolled_run.id, skip_refund=True
|
||||
)
|
||||
self.enrollment_course_run = None
|
||||
self.save()
|
||||
return unenrolled_run
|
||||
|
||||
@classmethod
|
||||
def unexpired_entitlements_for_user(cls, user):
|
||||
return cls.objects.filter(user=user, expired_at=None).select_related('user')
|
||||
@@ -334,9 +352,8 @@ class CourseEntitlementSupportDetail(TimeStampedModel):
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of an Entitlement"""
|
||||
return u'Course Entitlement Suppor Detail: entitlement: {}, support_user: {}, reason: {}'\
|
||||
.format(
|
||||
self.entitlement,
|
||||
self.support_user,
|
||||
self.reason,
|
||||
)
|
||||
return u'Course Entitlement Support Detail: entitlement: {}, support_user: {}, reason: {}'.format(
|
||||
self.entitlement,
|
||||
self.support_user,
|
||||
self.reason,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import itertools
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
import pytest
|
||||
@@ -19,10 +20,12 @@ from pytz import UTC
|
||||
from common.test.utils import disable_signal
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from entitlements.models import CourseEntitlementSupportDetail
|
||||
from entitlements.tests.factories import CourseEntitlementFactory
|
||||
from lms.djangoapps.verify_student.models import VerificationDeadline
|
||||
from student.models import ENROLLED_TO_ENROLLED, CourseEnrollment, ManualEnrollmentAudit
|
||||
from student.roles import GlobalStaff, SupportStaffRole
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
@@ -107,7 +110,8 @@ class SupportViewAccessTests(SupportViewTestCase):
|
||||
'support:enrollment',
|
||||
'support:enrollment_list',
|
||||
'support:manage_user',
|
||||
'support:manage_user_detail'
|
||||
'support:manage_user_detail',
|
||||
'support:enrollment_list'
|
||||
), (
|
||||
(GlobalStaff, True),
|
||||
(SupportStaffRole, True),
|
||||
@@ -135,7 +139,8 @@ class SupportViewAccessTests(SupportViewTestCase):
|
||||
"support:enrollment",
|
||||
"support:enrollment_list",
|
||||
"support:manage_user",
|
||||
"support:manage_user_detail"
|
||||
"support:manage_user_detail",
|
||||
"support:enrollment_list"
|
||||
)
|
||||
def test_require_login(self, url_name):
|
||||
url = reverse(url_name)
|
||||
@@ -432,3 +437,82 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase
|
||||
)
|
||||
verified_mode.expiration_datetime = datetime(year=1970, month=1, day=9, tzinfo=UTC)
|
||||
verified_mode.save()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class SupportViewCourseEntitlementsTests(SupportViewTestCase):
|
||||
""" Tests for the course entitlement support view."""
|
||||
|
||||
def setUp(self):
|
||||
super(SupportViewCourseEntitlementsTests, self).setUp()
|
||||
self.user = UserFactory(is_staff=True)
|
||||
SupportStaffRole().add_users(self.user)
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
|
||||
self.student = UserFactory.create(username='student', email='test@example.com', password='test')
|
||||
self.course_uuid = uuid4()
|
||||
|
||||
self.url = reverse('support:course_entitlement')
|
||||
|
||||
@ddt.data('username', 'email')
|
||||
def test_get_entitlements(self, search_string_type):
|
||||
CourseEntitlementFactory.create(mode=CourseMode.VERIFIED, user=self.student, course_uuid=self.course_uuid)
|
||||
url = self.url + getattr(self.student, search_string_type)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertDictContainsSubset({
|
||||
'user': self.student.username,
|
||||
'course_uuid': unicode(self.course_uuid),
|
||||
'enrollment_course_run': None,
|
||||
'mode': CourseMode.VERIFIED,
|
||||
'support_details': []
|
||||
}, data[0])
|
||||
|
||||
def test_reinstate_entitlement(self):
|
||||
selected_run = CourseEnrollmentFactory(mode=CourseMode.VERIFIED, user=self.student)
|
||||
expired_entitlement = CourseEntitlementFactory.create(
|
||||
mode=CourseMode.VERIFIED, user=self.student, enrollment_course_run=selected_run, expired_at=datetime.now()
|
||||
)
|
||||
url = self.url + self.student.username
|
||||
response = self.client.put(url, data=json.dumps(
|
||||
{
|
||||
'entitlement_uuid': unicode(expired_entitlement.uuid),
|
||||
'reason': CourseEntitlementSupportDetail.LEAVE_SESSION
|
||||
}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(len(data['support_details']), 1)
|
||||
self.assertDictContainsSubset({
|
||||
'support_user': self.user.username,
|
||||
'reason': CourseEntitlementSupportDetail.LEAVE_SESSION,
|
||||
'comments': None,
|
||||
'unenrolled_run': unicode(selected_run.course_id)
|
||||
}, data['support_details'][0])
|
||||
|
||||
def test_create_entitlement(self):
|
||||
CourseEntitlementFactory.create(
|
||||
mode=CourseMode.VERIFIED, user=self.student, course_uuid=self.course_uuid, expired_at=datetime.now()
|
||||
)
|
||||
url = self.url + self.student.username
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({
|
||||
'course_uuid': unicode(self.course_uuid),
|
||||
'reason': CourseEntitlementSupportDetail.LEARNER_REQUEST_NEW,
|
||||
'mode': CourseMode.VERIFIED
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(len(data['support_details']), 1)
|
||||
self.assertDictContainsSubset({
|
||||
'support_user': self.user.username,
|
||||
'reason': CourseEntitlementSupportDetail.LEARNER_REQUEST_NEW,
|
||||
'comments': None,
|
||||
'unenrolled_run': None
|
||||
}, data['support_details'][0])
|
||||
|
||||
@@ -3,25 +3,40 @@ URLs for the student support app.
|
||||
"""
|
||||
from django.conf.urls import url
|
||||
|
||||
from support import views
|
||||
|
||||
from lms.djangoapps.support.views.contact_us import ContactUsView
|
||||
from support.views.certificate import CertificatesSupportView
|
||||
from support.views.course_entitlements import EntitlementSupportView
|
||||
from support.views.enrollments import EnrollmentSupportListView, EnrollmentSupportView
|
||||
from support.views.index import index
|
||||
from support.views.manage_user import ManageUserDetailView, ManageUserSupportView
|
||||
from support.views.refund import RefundSupportView
|
||||
|
||||
COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view({
|
||||
'get': 'list',
|
||||
'post': 'create',
|
||||
'put': 'update'
|
||||
})
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^$', views.index, name="index"),
|
||||
url(r'^certificates/?$', views.CertificatesSupportView.as_view(), name="certificates"),
|
||||
url(r'^refund/?$', views.RefundSupportView.as_view(), name="refund"),
|
||||
url(r'^enrollment/?$', views.EnrollmentSupportView.as_view(), name="enrollment"),
|
||||
url(r'^$', index, name="index"),
|
||||
url(r'^certificates/?$', CertificatesSupportView.as_view(), name="certificates"),
|
||||
url(r'^refund/?$', RefundSupportView.as_view(), name="refund"),
|
||||
url(
|
||||
r'^course_entitlement/(?P<username_or_email>[\w.@+-]+)?$',
|
||||
COURSE_ENTITLEMENTS_VIEW,
|
||||
name="course_entitlement"
|
||||
),
|
||||
url(r'^enrollment/?$', EnrollmentSupportView.as_view(), name="enrollment"),
|
||||
url(r'^contact_us/?$', ContactUsView.as_view(), name="contact_us"),
|
||||
url(
|
||||
r'^enrollment/(?P<username_or_email>[\w.@+-]+)?$',
|
||||
views.EnrollmentSupportListView.as_view(),
|
||||
EnrollmentSupportListView.as_view(),
|
||||
name="enrollment_list"
|
||||
),
|
||||
url(r'^manage_user/?$', views.ManageUserSupportView.as_view(), name="manage_user"),
|
||||
url(r'^manage_user/?$', ManageUserSupportView.as_view(), name="manage_user"),
|
||||
url(
|
||||
r'^manage_user/(?P<username_or_email>[\w.@+-]+)?$',
|
||||
views.ManageUserDetailView.as_view(),
|
||||
ManageUserDetailView.as_view(),
|
||||
name="manage_user_detail"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
Aggregate all views for the support app.
|
||||
"""
|
||||
# pylint: disable=wildcard-import
|
||||
from .index import *
|
||||
from .certificate import *
|
||||
from .enrollments import *
|
||||
from .refund import *
|
||||
from .manage_user import *
|
||||
|
||||
125
lms/djangoapps/support/views/course_entitlements.py
Normal file
125
lms/djangoapps/support/views/course_entitlements.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Support tool for changing and granting course entitlements
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.utils.decorators import method_decorator
|
||||
from edx_rest_framework_extensions.authentication import JwtAuthentication
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.response import Response
|
||||
|
||||
from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly
|
||||
from entitlements.api.v1.serializers import SupportCourseEntitlementSerializer
|
||||
from entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
|
||||
from lms.djangoapps.support.decorators import require_support_permission
|
||||
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
|
||||
|
||||
REQUIRED_CREATION_FIELDS = ['course_uuid', 'reason', 'mode']
|
||||
|
||||
|
||||
class EntitlementSupportView(viewsets.ModelViewSet):
|
||||
"""
|
||||
Allows viewing and changing learner course entitlements, used the support team.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,)
|
||||
permission_classes = (permissions.IsAuthenticated, IsAdminOrAuthenticatedReadOnly,)
|
||||
queryset = CourseEntitlement.objects.all()
|
||||
serializer_class = SupportCourseEntitlementSerializer
|
||||
|
||||
@method_decorator(require_support_permission)
|
||||
def list(self, request, username_or_email): # pylint: disable=unused-argument
|
||||
"""
|
||||
Returns a list of course entitlements for the given user, along with details of any
|
||||
support team interactions with each of the course entitlements.
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email))
|
||||
except User.DoesNotExist:
|
||||
return Response([])
|
||||
|
||||
return Response(self.serializer_class(self.queryset.filter(user=user), many=True).data)
|
||||
|
||||
@method_decorator(require_support_permission)
|
||||
def update(self, request, username_or_email): # pylint: disable=unused-argument
|
||||
""" Allows support staff to update an existing course entitlement. """
|
||||
support_user = request.user
|
||||
entitlement_uuid = request.data.get('entitlement_uuid')
|
||||
if not entitlement_uuid:
|
||||
return HttpResponseBadRequest(u'The field {fieldname} is required.'.format(fieldname='entitlement_uuid'))
|
||||
reason = request.data.get('reason')
|
||||
if not reason:
|
||||
return HttpResponseBadRequest(u'The field {fieldname} is required.'.format(fieldname='reason'))
|
||||
comments = request.data.get('comments', None)
|
||||
try:
|
||||
entitlement = CourseEntitlement.objects.get(uuid=entitlement_uuid)
|
||||
except CourseEntitlement.DoesNotExist:
|
||||
return HttpResponseBadRequest(
|
||||
u'Could not find entitlement {entitlement_uuid} for update'.format(
|
||||
entitlement_uuid=entitlement_uuid
|
||||
)
|
||||
)
|
||||
if reason == CourseEntitlementSupportDetail.LEAVE_SESSION:
|
||||
return self._reinstate_entitlement(support_user, entitlement, comments)
|
||||
|
||||
def _reinstate_entitlement(self, support_user, entitlement, comments):
|
||||
""" Allows support staff to unexpire a user's entitlement."""
|
||||
if entitlement.enrollment_course_run is None:
|
||||
return HttpResponseBadRequest(
|
||||
u"Entitlement {entitlement} has not been spent on a course run.".format(
|
||||
entitlement=entitlement
|
||||
)
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
unenrolled_run = entitlement.reinstate()
|
||||
CourseEntitlementSupportDetail.objects.create(
|
||||
entitlement=entitlement, reason=CourseEntitlementSupportDetail.LEAVE_SESSION, comments=comments,
|
||||
unenrolled_run=unenrolled_run, support_user=support_user
|
||||
)
|
||||
return Response(
|
||||
data=SupportCourseEntitlementSerializer(instance=entitlement).data
|
||||
)
|
||||
except DatabaseError:
|
||||
return HttpResponseBadRequest(
|
||||
u'Failed to reinstate entitlement {entitlement}'.format(entitlement=entitlement))
|
||||
|
||||
@method_decorator(require_support_permission)
|
||||
def create(self, request, username_or_email): # pylint: disable=arguments-differ
|
||||
""" Allows support staff to grant a user a new entitlement for a course. """
|
||||
support_user = request.user
|
||||
comments = request.data.get('comments', None)
|
||||
|
||||
creation_fields = {}
|
||||
missing_fields_string = ''
|
||||
for field in REQUIRED_CREATION_FIELDS:
|
||||
creation_fields[field] = request.data.get(field)
|
||||
if not creation_fields.get(field):
|
||||
missing_fields_string = missing_fields_string + ' ' + field
|
||||
if missing_fields_string:
|
||||
return HttpResponseBadRequest(
|
||||
u'The following required fields are missing from the request:{missing_fields}'.format(
|
||||
missing_fields=missing_fields_string
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email))
|
||||
except User.DoesNotExist:
|
||||
return HttpResponseBadRequest(
|
||||
u'Could not find user {username_or_email}.'.format(
|
||||
username_or_email=username_or_email,
|
||||
)
|
||||
)
|
||||
|
||||
entitlement = CourseEntitlement.objects.create(
|
||||
user=user, course_uuid=creation_fields['course_uuid'], mode=creation_fields['mode']
|
||||
)
|
||||
CourseEntitlementSupportDetail.objects.create(
|
||||
entitlement=entitlement, reason=creation_fields['reason'], comments=comments, support_user=support_user
|
||||
)
|
||||
return Response(
|
||||
status=status.HTTP_201_CREATED,
|
||||
data=SupportCourseEntitlementSerializer(instance=entitlement).data
|
||||
)
|
||||
@@ -31,6 +31,11 @@ SUPPORT_INDEX_URLS = [
|
||||
"name": _("Manage User"),
|
||||
"description": _("Disable User Account"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("support:course_entitlement"),
|
||||
"name": _("Course Entitlement"),
|
||||
"description": _("View, update, and grant course entitlements for users.")
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user