From a31855c93574c486f3afb63bc6cb80a8f39f9c40 Mon Sep 17 00:00:00 2001
From: Brandon DeRosier <x@bdero.me>
Date: Fri, 31 Mar 2017 14:36:55 -0400
Subject: [PATCH] Upstream the Bulk Enroll API endpoint

---
 lms/djangoapps/bulk_enroll/__init__.py        |   0
 lms/djangoapps/bulk_enroll/serializers.py     |  44 +++
 lms/djangoapps/bulk_enroll/tests/__init__.py  |   0
 .../bulk_enroll/tests/test_views.py           | 308 ++++++++++++++++++
 lms/djangoapps/bulk_enroll/urls.py            |  11 +
 lms/djangoapps/bulk_enroll/views.py           |  75 +++++
 lms/envs/common.py                            |   6 +
 lms/envs/test.py                              |   2 +
 lms/urls.py                                   |   7 +
 openedx/core/lib/api/permissions.py           |  10 +
 10 files changed, 463 insertions(+)
 create mode 100644 lms/djangoapps/bulk_enroll/__init__.py
 create mode 100644 lms/djangoapps/bulk_enroll/serializers.py
 create mode 100644 lms/djangoapps/bulk_enroll/tests/__init__.py
 create mode 100644 lms/djangoapps/bulk_enroll/tests/test_views.py
 create mode 100644 lms/djangoapps/bulk_enroll/urls.py
 create mode 100644 lms/djangoapps/bulk_enroll/views.py

diff --git a/lms/djangoapps/bulk_enroll/__init__.py b/lms/djangoapps/bulk_enroll/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/lms/djangoapps/bulk_enroll/serializers.py b/lms/djangoapps/bulk_enroll/serializers.py
new file mode 100644
index 00000000000..9ac371805db
--- /dev/null
+++ b/lms/djangoapps/bulk_enroll/serializers.py
@@ -0,0 +1,44 @@
+"""
+Serializers for Bulk Enrollment.
+"""
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from rest_framework import serializers
+
+
+class StringListField(serializers.ListField):
+    def to_internal_value(self, data):
+        try:
+            return data[0].split(',')
+        except IndexError:
+            return []
+
+
+class BulkEnrollmentSerializer(serializers.Serializer):
+    """Serializes enrollment information for a collection of students/emails.
+
+    This is mainly useful for implementing validation when performing bulk enrollment operations.
+    """
+    identifiers = serializers.CharField(required=True)
+    courses = StringListField(required=True)
+    action = serializers.ChoiceField(
+        choices=(
+            ('enroll', 'enroll'),
+            ('unenroll', 'unenroll')
+        ),
+        required=True
+    )
+    auto_enroll = serializers.BooleanField(default=False)
+    email_students = serializers.BooleanField(default=False)
+
+    def validate_courses(self, value):
+        """
+        Check that each course key in list is valid.
+        """
+        course_keys = value
+        for course in course_keys:
+            try:
+                CourseKey.from_string(course)
+            except InvalidKeyError:
+                raise serializers.ValidationError("Course key not valid: {}".format(course))
+        return value
diff --git a/lms/djangoapps/bulk_enroll/tests/__init__.py b/lms/djangoapps/bulk_enroll/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/lms/djangoapps/bulk_enroll/tests/test_views.py b/lms/djangoapps/bulk_enroll/tests/test_views.py
new file mode 100644
index 00000000000..4a2ca20faf4
--- /dev/null
+++ b/lms/djangoapps/bulk_enroll/tests/test_views.py
@@ -0,0 +1,308 @@
+"""
+Tests for the Bulk Enrollment views.
+"""
+import json
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.core import mail
+from django.core.urlresolvers import reverse
+from django.test.utils import override_settings
+from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate
+
+from bulk_enroll.views import BulkEnrollView
+from courseware.tests.helpers import LoginEnrollmentTestCase
+from microsite_configuration import microsite
+from student.models import (
+    CourseEnrollment,
+    ManualEnrollmentAudit,
+    ENROLLED_TO_UNENROLLED,
+    UNENROLLED_TO_ENROLLED,
+)
+from student.tests.factories import UserFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+
+
+@override_settings(ENABLE_BULK_ENROLLMENT_VIEW=True)
+class BulkEnrollmentTest(ModuleStoreTestCase, LoginEnrollmentTestCase, APITestCase):
+    """
+    Test the bulk enrollment endpoint
+    """
+
+    USERNAME = "Bob"
+    EMAIL = "bob@example.com"
+    PASSWORD = "edx"
+
+    def setUp(self):
+        """ Create a course and user, then log in. """
+        super(BulkEnrollmentTest, self).setUp()
+
+        self.view = BulkEnrollView.as_view()
+        self.request_factory = APIRequestFactory()
+        self.url = reverse('bulk_enroll')
+
+        self.staff = UserFactory.create(
+            username=self.USERNAME,
+            email=self.EMAIL,
+            password=self.PASSWORD,
+            is_staff=True,
+        )
+
+        self.course = CourseFactory.create()
+        self.course_key = unicode(self.course.id)
+        self.enrolled_student = UserFactory(username='EnrolledStudent', first_name='Enrolled', last_name='Student')
+        CourseEnrollment.enroll(
+            self.enrolled_student,
+            self.course.id
+        )
+        self.notenrolled_student = UserFactory(username='NotEnrolledStudent', first_name='NotEnrolled',
+                                               last_name='Student')
+
+        # Email URL values
+        self.site_name = microsite.get_value(
+            'SITE_NAME',
+            settings.SITE_NAME
+        )
+        self.about_path = '/courses/{}/about'.format(self.course.id)
+        self.course_path = '/courses/{}/'.format(self.course.id)
+
+    def request_bulk_enroll(self, data=None, **extra):
+        """ Make an authenticated request to the bulk enrollment API. """
+        request = self.request_factory.post(self.url, data=data, **extra)
+        force_authenticate(request, user=self.staff)
+        response = self.view(request)
+        response.render()
+        return response
+
+    def test_non_staff(self):
+        """ Test that non global staff users are forbidden from API use. """
+        self.staff.is_staff = False
+        self.staff.save()
+        response = self.request_bulk_enroll()
+        self.assertEqual(response.status_code, 403)
+
+    def test_missing_params(self):
+        """ Test the response when missing all query parameters. """
+        response = self.request_bulk_enroll()
+        self.assertEqual(response.status_code, 400)
+
+    def test_bad_action(self):
+        """ Test the response given an invalid action """
+        response = self.request_bulk_enroll({
+            'identifiers': self.enrolled_student.email,
+            'action': 'invalid-action',
+            'courses': self.course_key,
+        })
+        self.assertEqual(response.status_code, 400)
+
+    def test_invalid_email(self):
+        """ Test the response given an invalid email. """
+        response = self.request_bulk_enroll({
+            'identifiers': 'percivaloctavius@',
+            'action': 'enroll',
+            'email_students': False,
+            'courses': self.course_key,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        # test the response data
+        expected = {
+            "action": "enroll",
+            'auto_enroll': False,
+            'email_students': False,
+            "courses": {
+                self.course_key: {
+                    "action": "enroll",
+                    'auto_enroll': False,
+                    "results": [
+                        {
+                            "identifier": 'percivaloctavius@',
+                            "invalidIdentifier": True,
+                        }
+                    ]
+                }
+            }
+        }
+
+        res_json = json.loads(response.content)
+        self.assertEqual(res_json, expected)
+
+    def test_invalid_username(self):
+        """ Test the response given an invalid username. """
+        response = self.request_bulk_enroll({
+            'identifiers': 'percivaloctavius',
+            'action': 'enroll',
+            'email_students': False,
+            'courses': self.course_key,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        # test the response data
+        expected = {
+            "action": "enroll",
+            'auto_enroll': False,
+            'email_students': False,
+            "courses": {
+                self.course_key: {
+                    "action": "enroll",
+                    'auto_enroll': False,
+                    "results": [
+                        {
+                            "identifier": 'percivaloctavius',
+                            "invalidIdentifier": True,
+                        }
+                    ]
+                }
+            }
+        }
+
+        res_json = json.loads(response.content)
+        self.assertEqual(res_json, expected)
+
+    def test_enroll_with_username(self):
+        """ Test enrolling using a username as the identifier. """
+        response = self.request_bulk_enroll({
+            'identifiers': self.notenrolled_student.username,
+            'action': 'enroll',
+            'email_students': False,
+            'courses': self.course_key,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        # test the response data
+        expected = {
+            "action": "enroll",
+            'auto_enroll': False,
+            "email_students": False,
+            "courses": {
+                self.course_key: {
+                    "action": "enroll",
+                    'auto_enroll': False,
+                    "results": [
+                        {
+                            "identifier": self.notenrolled_student.username,
+                            "before": {
+                                "enrollment": False,
+                                "auto_enroll": False,
+                                "user": True,
+                                "allowed": False,
+                            },
+                            "after": {
+                                "enrollment": True,
+                                "auto_enroll": False,
+                                "user": True,
+                                "allowed": False,
+                            }
+                        }
+                    ]
+                }
+            }
+        }
+        manual_enrollments = ManualEnrollmentAudit.objects.all()
+        self.assertEqual(manual_enrollments.count(), 1)
+        self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED)
+        res_json = json.loads(response.content)
+        self.assertEqual(res_json, expected)
+
+    def test_enroll_with_email(self):
+        """ Test enrolling using a username as the identifier. """
+        response = self.request_bulk_enroll({
+            'identifiers': self.notenrolled_student.email,
+            'action': 'enroll',
+            'email_students': False,
+            'courses': self.course_key,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        # test that the user is now enrolled
+        user = User.objects.get(email=self.notenrolled_student.email)
+        self.assertTrue(CourseEnrollment.is_enrolled(user, self.course.id))
+
+        # test the response data
+        expected = {
+            "action": "enroll",
+            "auto_enroll": False,
+            "email_students": False,
+            "courses": {
+                self.course_key: {
+                    "action": "enroll",
+                    "auto_enroll": False,
+                    "results": [
+                        {
+                            "identifier": self.notenrolled_student.email,
+                            "before": {
+                                "enrollment": False,
+                                "auto_enroll": False,
+                                "user": True,
+                                "allowed": False,
+                            },
+                            "after": {
+                                "enrollment": True,
+                                "auto_enroll": False,
+                                "user": True,
+                                "allowed": False,
+                            }
+                        }
+                    ]
+                }
+            }
+        }
+
+        manual_enrollments = ManualEnrollmentAudit.objects.all()
+        self.assertEqual(manual_enrollments.count(), 1)
+        self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED)
+        res_json = json.loads(response.content)
+        self.assertEqual(res_json, expected)
+
+        # Check the outbox
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_unenroll(self):
+        """ Test unenrolling a user. """
+        response = self.request_bulk_enroll({'identifiers': self.enrolled_student.email, 'action': 'unenroll',
+                                             'email_students': False, 'courses': self.course_key, })
+        self.assertEqual(response.status_code, 200)
+
+        # test that the user is now unenrolled
+        user = User.objects.get(email=self.enrolled_student.email)
+        self.assertFalse(CourseEnrollment.is_enrolled(user, self.course.id))
+
+        # test the response data
+        expected = {
+            "action": "unenroll",
+            "auto_enroll": False,
+            "email_students": False,
+            "courses": {
+                self.course_key: {
+                    "action": "unenroll",
+                    "auto_enroll": False,
+                    "results": [
+                        {
+                            "identifier": self.enrolled_student.email,
+                            "before": {
+                                "enrollment": True,
+                                "auto_enroll": False,
+                                "user": True,
+                                "allowed": False,
+                            },
+                            "after": {
+                                "enrollment": False,
+                                "auto_enroll": False,
+                                "user": True,
+                                "allowed": False,
+                            }
+                        }
+                    ]
+                }
+            }
+
+        }
+
+        manual_enrollments = ManualEnrollmentAudit.objects.all()
+        self.assertEqual(manual_enrollments.count(), 1)
+        self.assertEqual(manual_enrollments[0].state_transition, ENROLLED_TO_UNENROLLED)
+        res_json = json.loads(response.content)
+        self.assertEqual(res_json, expected)
+
+        # Check the outbox
+        self.assertEqual(len(mail.outbox), 0)
diff --git a/lms/djangoapps/bulk_enroll/urls.py b/lms/djangoapps/bulk_enroll/urls.py
new file mode 100644
index 00000000000..1aba792f4a8
--- /dev/null
+++ b/lms/djangoapps/bulk_enroll/urls.py
@@ -0,0 +1,11 @@
+"""
+URLs for the Bulk Enrollment API
+"""
+from django.conf.urls import patterns, url
+
+from bulk_enroll.views import BulkEnrollView
+
+urlpatterns = patterns(
+    'bulk_enroll.views',
+    url(r'^bulk_enroll', BulkEnrollView.as_view(), name='bulk_enroll'),
+)
diff --git a/lms/djangoapps/bulk_enroll/views.py b/lms/djangoapps/bulk_enroll/views.py
new file mode 100644
index 00000000000..2bec038c643
--- /dev/null
+++ b/lms/djangoapps/bulk_enroll/views.py
@@ -0,0 +1,75 @@
+"""
+API views for Bulk Enrollment
+"""
+import json
+from edx_rest_framework_extensions.authentication import JwtAuthentication
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from bulk_enroll.serializers import BulkEnrollmentSerializer
+from enrollment.views import EnrollmentUserThrottle
+from instructor.views.api import students_update_enrollment
+from openedx.core.lib.api.authentication import OAuth2Authentication
+from openedx.core.lib.api.permissions import IsStaff
+from util.disable_rate_limit import can_disable_rate_limit
+
+
+@can_disable_rate_limit
+class BulkEnrollView(APIView):
+    """
+    **Use Case**
+
+        Enroll multiple users in one or more courses.
+
+    **Example Request**
+
+        POST /api/bulk_enroll/v1/bulk_enroll/ {
+            "auto_enroll": true,
+            "email_students": true,
+            "action": "enroll",
+            "courses": "course-v1:edX+Demo+123,course-v1:edX+Demo2+456",
+            "identifiers": "brandon@example.com,yamilah@example.com"
+        }
+
+        **POST Parameters**
+
+          A POST request can include the following parameters.
+
+          * auto_enroll: When set to `true`, students will be enrolled as soon
+            as they register.
+          * email_students: When set to `true`, students will be sent email
+            notifications upon enrollment.
+          * action: Can either be set to "enroll" or "unenroll". This determines the behabior
+
+    **Response Values**
+
+        If the supplied course data is valid and the enrollments were
+        successful, an HTTP 200 "OK" response is returned.
+
+        The HTTP 200 response body contains a list of response data for each
+        enrollment. (See the `instructor.views.api.students_update_enrollment`
+        docstring for the specifics of the response data available for each
+        enrollment)
+    """
+
+    authentication_classes = JwtAuthentication, OAuth2Authentication
+    permission_classes = IsStaff,
+    throttle_classes = EnrollmentUserThrottle,
+
+    def post(self, request):
+        serializer = BulkEnrollmentSerializer(data=request.data)
+        if serializer.is_valid():
+            request.POST = request.data
+            response_dict = {
+                'auto_enroll': serializer.data.get('auto_enroll'),
+                'email_students': serializer.data.get('email_students'),
+                'action': serializer.data.get('action'),
+                'courses': {}
+            }
+            for course in serializer.data.get('courses'):
+                response = students_update_enrollment(self.request, course_id=course)
+                response_dict['courses'][course] = json.loads(response.content)
+            return Response(data=response_dict, status=status.HTTP_200_OK)
+        else:
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index b0fea16b843..5c18f651c66 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -392,6 +392,9 @@ FEATURES = {
     # Whether to check the "Notify users by email" checkbox in the batch enrollment form
     # in the instructor dashboard.
     'BATCH_ENROLLMENT_NOTIFY_USERS_DEFAULT': True,
+
+    # Whether the bulk enrollment view is enabled.
+    'ENABLE_BULK_ENROLLMENT_VIEW': False,
 }
 
 # Settings for the course reviews tool template and identification key, set either to None to disable course reviews
@@ -2110,6 +2113,9 @@ INSTALLED_APPS = (
     # Enrollment API
     'enrollment',
 
+    # Bulk Enrollment API
+    'bulk_enroll',
+
     # Student Identity Verification
     'lms.djangoapps.verify_student',
 
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 41388e6223f..f980451e997 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -80,6 +80,8 @@ FEATURES['MILESTONES_APP'] = True
 
 FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
 
+FEATURES['ENABLE_BULK_ENROLLMENT_VIEW'] = True
+
 # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
 WIKI_ENABLED = True
 
diff --git a/lms/urls.py b/lms/urls.py
index d0a41858a24..6d2f0286a0a 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -806,6 +806,13 @@ if settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'):
 
     )
 
+if configuration_helpers.get_value('ENABLE_BULK_ENROLLMENT_VIEW',
+                                   settings.FEATURES['ENABLE_BULK_ENROLLMENT_VIEW']):
+    urlpatterns += (
+        url(r'^api/bulk_enroll/v1/', include('bulk_enroll.urls')),
+    )
+
+
 # Shopping cart
 urlpatterns += (
     url(r'^shoppingcart/', include('shoppingcart.urls')),
diff --git a/openedx/core/lib/api/permissions.py b/openedx/core/lib/api/permissions.py
index d15b9c739a9..c71381f2cd3 100644
--- a/openedx/core/lib/api/permissions.py
+++ b/openedx/core/lib/api/permissions.py
@@ -119,6 +119,16 @@ class IsMasterCourseStaffInstructor(permissions.BasePermission):
         return False
 
 
+class IsStaff(permissions.BasePermission):
+    """
+    Permission that checks to see if the request user has is_staff access.
+    """
+
+    def has_permission(self, request, view):
+        if request.user.is_staff:
+            return True
+
+
 class IsUserInUrlOrStaff(IsUserInUrl):
     """
     Permission that checks to see if the request user matches the user in the URL or has is_staff access.
-- 
GitLab