diff --git a/common/djangoapps/entitlements/api/v1/serializers.py b/common/djangoapps/entitlements/api/v1/serializers.py index 4aed2c1478..bb7660626e 100644 --- a/common/djangoapps/entitlements/api/v1/serializers.py +++ b/common/djangoapps/entitlements/api/v1/serializers.py @@ -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', ) diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index 36d3b5d9b1..32179785e0 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -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, + ) diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index a67886668c..32b8489ad9 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -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]) diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index 5c5019984e..1bd324d23a 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -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[\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[\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[\w.@+-]+)?$', - views.ManageUserDetailView.as_view(), + ManageUserDetailView.as_view(), name="manage_user_detail" ), ] diff --git a/lms/djangoapps/support/views/__init__.py b/lms/djangoapps/support/views/__init__.py index 55db08fce7..e69de29bb2 100644 --- a/lms/djangoapps/support/views/__init__.py +++ b/lms/djangoapps/support/views/__init__.py @@ -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 * diff --git a/lms/djangoapps/support/views/course_entitlements.py b/lms/djangoapps/support/views/course_entitlements.py new file mode 100644 index 0000000000..79d64fdced --- /dev/null +++ b/lms/djangoapps/support/views/course_entitlements.py @@ -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 + ) diff --git a/lms/djangoapps/support/views/index.py b/lms/djangoapps/support/views/index.py index e1ccb0ebb6..07d512824a 100644 --- a/lms/djangoapps/support/views/index.py +++ b/lms/djangoapps/support/views/index.py @@ -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.") + }, ]