Skip to content
Snippets Groups Projects
Unverified Commit fa90d6e6 authored by McKenzie Welter's avatar McKenzie Welter Committed by GitHub
Browse files

Merge pull request #17640 from edx/McKenzieW/learner-4400

Move support actions to existing entitlement api
parents e9d6c853 e5d1f04a
No related branches found
No related tags found
No related merge requests found
......@@ -5,15 +5,17 @@ requiring Superuser access for all other Request types on an API endpoint.
from rest_framework.permissions import BasePermission, SAFE_METHODS
from courseware.access import has_access
class IsAdminOrAuthenticatedReadOnly(BasePermission):
class IsAdminOrSupportOrAuthenticatedReadOnly(BasePermission):
"""
Method that will require staff access for all methods not
Method that will require admin or support access for all methods not
in the SAFE_METHODS list. For example GET requests will not
require a Staff or Admin user.
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
return request.user.is_staff or has_access(request.user, "support", "global")
......@@ -15,6 +15,14 @@ class CourseEntitlementSerializer(serializers.ModelSerializer):
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
......@@ -27,7 +35,8 @@ class CourseEntitlementSerializer(serializers.ModelSerializer):
'created',
'modified',
'mode',
'order_number'
'order_number',
'support_details'
)
......@@ -44,25 +53,7 @@ class CourseEntitlementSupportDetailSerializer(serializers.ModelSerializer):
model = CourseEntitlementSupportDetail
fields = (
'support_user',
'reason',
'action',
'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', )
......@@ -31,6 +31,7 @@ class EntitlementsSerializerTests(ModuleStoreTestCase):
'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
......@@ -2,7 +2,7 @@ import json
import logging
import unittest
import uuid
from datetime import timedelta
from datetime import datetime, timedelta
from django.conf import settings
from django.core.urlresolvers import reverse
......@@ -53,7 +53,7 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
"user": user.username,
"mode": "verified",
"course_uuid": course_uuid,
"order_number": "EDX-1001"
"order_number": "EDX-1001",
}
def test_auth_required(self):
......@@ -124,6 +124,33 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
)
assert results == CourseEntitlementSerializer(course_entitlement).data
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.api.v1.views.get_course_runs_for_course")
def test_add_entitlement_and_upgrade_audit_enrollment(self, mock_get_course_runs):
"""
......@@ -333,6 +360,38 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
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': '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
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
......
import logging
from django.db import IntegrityError, transaction
from django.http import HttpResponseBadRequest
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from edx_rest_framework_extensions.authentication import JwtAuthentication
......@@ -12,12 +13,13 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from entitlements.api.v1.filters import CourseEntitlementFilter
from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly
from entitlements.api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly
from entitlements.api.v1.serializers import CourseEntitlementSerializer
from entitlements.models import CourseEntitlement
from entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
from entitlements.utils import is_course_run_entitlement_fulfillable
from lms.djangoapps.commerce.utils import refund_entitlement
from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from student.models import CourseEnrollment
from student.models import CourseEnrollmentException, AlreadyEnrolledError
......@@ -91,7 +93,7 @@ class EntitlementViewSet(viewsets.ModelViewSet):
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, IsAdminOrAuthenticatedReadOnly,)
permission_classes = (permissions.IsAuthenticated, IsAdminOrSupportOrAuthenticatedReadOnly,)
lookup_value_regex = ENTITLEMENT_UUID4_REGEX
lookup_field = 'uuid'
serializer_class = CourseEntitlementSerializer
......@@ -119,53 +121,61 @@ class EntitlementViewSet(viewsets.ModelViewSet):
return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run')
def create(self, request, *args, **kwargs):
support_details = request.data.pop('support_details', [])
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
entitlement = serializer.instance
user = entitlement.user
# 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
user_run_enrollments = [
CourseEnrollment.get_enrollment(user, CourseKey.from_string(course_run.get('key')))
for course_run
in course_runs
if CourseEnrollment.get_enrollment(user, CourseKey.from_string(course_run.get('key')))
]
# filter to just enrollments that can be upgraded.
upgradeable_enrollments = [
enrollment
for enrollment
in user_run_enrollments
if enrollment.is_active and enrollment.upgrade_deadline and enrollment.upgrade_deadline > timezone.now()
]
# if there is only one upgradeable enrollment, convert it from audit to the entitlement.mode
# if there is any ambiguity about which enrollment to upgrade
# (i.e. multiple upgradeable enrollments or no available upgradeable enrollment), dont enroll
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)
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:
log.info(
'No enrollment upgraded while adding entitlement for user [%s] for course [%s] ',
user.username,
serializer.data.get('course_uuid')
)
user = entitlement.user
# 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
user_run_enrollments = [
CourseEnrollment.get_enrollment(user, CourseKey.from_string(course_run.get('key')))
for course_run
in course_runs
if CourseEnrollment.get_enrollment(user, CourseKey.from_string(course_run.get('key')))
]
# filter to just enrollments that can be upgraded.
upgradeable_enrollments = [
enrollment
for enrollment
in user_run_enrollments
if enrollment.is_active and enrollment.upgrade_deadline and enrollment.upgrade_deadline > timezone.now()
]
# if there is only one upgradeable enrollment, convert it from audit to the entitlement.mode
# if there is any ambiguity about which enrollment to upgrade
# (i.e. multiple upgradeable enrollments or no available upgradeable enrollment), dont enroll
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,
......@@ -221,6 +231,37 @@ class EntitlementViewSet(viewsets.ModelViewSet):
# 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', [])
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)
class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
"""
......
......@@ -263,23 +263,6 @@ 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')
......
......@@ -7,7 +7,6 @@ import itertools
import json
import re
from datetime import datetime, timedelta
from uuid import uuid4
import ddt
import pytest
......@@ -20,8 +19,6 @@ 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
......@@ -111,7 +108,6 @@ class SupportViewAccessTests(SupportViewTestCase):
'support:enrollment_list',
'support:manage_user',
'support:manage_user_detail',
'support:enrollment_list'
), (
(GlobalStaff, True),
(SupportStaffRole, True),
......@@ -140,7 +136,6 @@ class SupportViewAccessTests(SupportViewTestCase):
"support:enrollment_list",
"support:manage_user",
"support:manage_user_detail",
"support:enrollment_list"
)
def test_require_login(self, url_name):
url = reverse(url_name)
......
......@@ -17,8 +17,8 @@ urlpatterns = [
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/?$', COURSE_ENTITLEMENTS_VIEW, name="course_entitlement"),
url(r'^enrollment/?$', EnrollmentSupportView.as_view(), name="enrollment"),
url(r'^course_entitlement/?$', COURSE_ENTITLEMENTS_VIEW, name="course_entitlement"),
url(r'^contact_us/?$', ContactUsView.as_view(), name="contact_us"),
url(
r'^enrollment/(?P<username_or_email>[\w.@+-]+)?$',
......
......@@ -12,8 +12,6 @@ from rest_framework import permissions, status, viewsets
from rest_framework.response import Response
from edxmako.shortcuts import render_to_response
from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly
from entitlements.api.v1.serializers import SupportCourseEntitlementSerializer
from entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.support.decorators import require_support_permission
......@@ -40,109 +38,3 @@ class EntitlementSupportView(View):
'support_actions': support_actions
}
return render_to_response('support/entitlement.html', context)
class EntitlementSupportListView(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
)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment