From 9526bbc9ebed194cc595d96661420f2fcd00b960 Mon Sep 17 00:00:00 2001
From: Sanford Student <sstudent@edx.org>
Date: Wed, 4 Apr 2018 12:13:09 -0400
Subject: [PATCH] Create API endpoint to unenroll user from all courses;
 EDUCATOR-2603

---
 common/djangoapps/enrollment/api.py           |   9 ++
 common/djangoapps/enrollment/data.py          |  16 +++
 .../djangoapps/enrollment/tests/test_views.py | 129 +++++++++++++++++-
 common/djangoapps/enrollment/urls.py          |   3 +-
 common/djangoapps/enrollment/views.py         |  72 +++++++++-
 5 files changed, 220 insertions(+), 9 deletions(-)

diff --git a/common/djangoapps/enrollment/api.py b/common/djangoapps/enrollment/api.py
index b8c4a535ca9..2645b5dcb4d 100644
--- a/common/djangoapps/enrollment/api.py
+++ b/common/djangoapps/enrollment/api.py
@@ -456,6 +456,15 @@ def validate_course_mode(course_id, mode, is_active=None, include_expired=False)
         raise errors.CourseModeNotFoundError(msg, course_enrollment_info)
 
 
+def unenroll_user_from_all_courses(user_id):
+    """
+    Unenrolls a specified user from all of the courses they are currently enrolled in.
+    :param user_id: The id of the user being unenrolled.
+    :return: The IDs of all of the organizations from which the learner was unenrolled.
+    """
+    return _data_api().unenroll_user_from_all_courses(user_id)
+
+
 def _data_api():
     """Returns a Data API.
     This relies on Django settings to find the appropriate data API.
diff --git a/common/djangoapps/enrollment/data.py b/common/djangoapps/enrollment/data.py
index 444db564b41..6a9f44b3468 100644
--- a/common/djangoapps/enrollment/data.py
+++ b/common/djangoapps/enrollment/data.py
@@ -5,6 +5,7 @@ source to be used throughout the API.
 import logging
 
 from django.contrib.auth.models import User
+from django.db import transaction
 from opaque_keys.edx.keys import CourseKey
 from six import text_type
 
@@ -221,6 +222,21 @@ def get_enrollment_attributes(user_id, course_id):
     return CourseEnrollmentAttribute.get_enrollment_attributes(enrollment)
 
 
+def unenroll_user_from_all_courses(user_id):
+    """
+    Set all of a user's enrollments to inactive.
+    :param user_id: The user being unenrolled.
+    :return: A list of all courses from which the user was unenrolled.
+    """
+    user = _get_user(user_id)
+    enrollments = CourseEnrollment.objects.filter(user=user)
+    with transaction.atomic():
+        for enrollment in enrollments:
+            _update_enrollment(enrollment, is_active=False)
+
+    return set([str(enrollment.course_id.org) for enrollment in enrollments])
+
+
 def _get_user(user_id):
     """Retrieve user with provided user_id
 
diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py
index 98ad08336ed..a74dea421fa 100644
--- a/common/djangoapps/enrollment/tests/test_views.py
+++ b/common/djangoapps/enrollment/tests/test_views.py
@@ -32,10 +32,11 @@ from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, R
 from openedx.core.djangoapps.embargo.test_utils import restrict_course
 from openedx.core.djangoapps.user_api.models import UserOrgTag
 from openedx.core.lib.django_test_client_utils import get_absolute_url
+from openedx.core.lib.token_utils import JwtBuilder
 from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
 from student.models import CourseEnrollment
 from student.roles import CourseStaffRole
-from student.tests.factories import AdminFactory, UserFactory
+from student.tests.factories import AdminFactory, UserFactory, SuperuserFactory
 from util.models import RateLimitConfiguration
 from util.testing import UrlResetMixin
 
@@ -132,6 +133,11 @@ class EnrollmentTestMixin(object):
         self.assertEqual(actual_activation, expected_activation)
         self.assertEqual(actual_mode, expected_mode)
 
+    def _get_enrollments(self):
+        """Retrieve the enrollment list for the current user. """
+        resp = self.client.get(reverse("courseenrollments"))
+        return json.loads(resp.content)
+
 
 @attr(shard=3)
 @override_settings(EDX_API_KEY="i am a key")
@@ -163,7 +169,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, Ente
         self.rate_limit, __ = throttle.parse_rate(throttle.rate)
 
         # Pass emit_signals when creating the course so it would be cached
-        # as a CourseOverview.
+        # as a CourseOverview. Enrollments require a cached CourseOverview.
         self.course = CourseFactory.create(emit_signals=True)
 
         self.user = UserFactory.create(
@@ -1122,11 +1128,6 @@ class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestC
         # Verify that we were enrolled
         self.assertEqual(len(self._get_enrollments()), 1)
 
-    def _get_enrollments(self):
-        """Retrieve the enrollment list for the current user. """
-        resp = self.client.get(self.url)
-        return json.loads(resp.content)
-
 
 def cross_domain_config(func):
     """Decorator for configuring a cross-domain request. """
@@ -1204,3 +1205,117 @@ class EnrollmentCrossDomainTest(ModuleStoreTestCase):
             HTTP_REFERER=self.REFERER,
             HTTP_X_CSRFTOKEN=csrf_cookie
         )
+
+
+@ddt.ddt
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
+class UnenrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase):
+    """
+    Tests unenrollment functionality. The API being tested is intended to
+    unenroll a learner from all of their courses.g
+    """
+    USERNAME = "Bob"
+    EMAIL = "bob@example.com"
+    PASSWORD = "edx"
+
+    ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache']
+    ENABLED_SIGNALS = ['course_published']
+
+    def setUp(self):
+        """ Create a course and user, then log in. """
+        super(UnenrollmentTest, self).setUp()
+        self.superuser = SuperuserFactory()
+        # Pass emit_signals when creating the course so it would be cached
+        # as a CourseOverview. Enrollments require a cached CourseOverview.
+        self.first_org_course = CourseFactory.create(emit_signals=True, org="org", course="course", run="run")
+        self.other_first_org_course = CourseFactory.create(emit_signals=True, org="org", course="course2", run="run2")
+        self.second_org_course = CourseFactory.create(emit_signals=True, org="org2", course="course3", run="run3")
+        self.third_org_course = CourseFactory.create(emit_signals=True, org="org3", course="course4", run="run4")
+
+        self.courses = [
+            self.first_org_course, self.other_first_org_course, self.second_org_course, self.third_org_course
+        ]
+
+        self.orgs = {"org", "org2", "org3"}
+
+        for course in self.courses:
+            CourseModeFactory.create(
+                course_id=str(course.id),
+                mode_slug=CourseMode.DEFAULT_MODE_SLUG,
+                mode_display_name=CourseMode.DEFAULT_MODE,
+            )
+
+        self.user = UserFactory.create(
+            username=self.USERNAME,
+            email=self.EMAIL,
+            password=self.PASSWORD,
+        )
+        self.client.login(username=self.USERNAME, password=self.PASSWORD)
+        for course in self.courses:
+            self.assert_enrollment_status(course_id=str(course.id), username=self.USERNAME, is_active=True)
+
+    def build_jwt_headers(self, user):
+        """
+        Helper function for creating headers for the JWT authentication.
+        """
+        token = JwtBuilder(user).build_token([])
+        headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
+
+        return headers
+
+    def test_deactivate_enrollments(self):
+        self._assert_active()
+        response = self._submit_unenroll(self.superuser, self.user.username)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        # order doesn't matter so compare sets
+        self.assertEqual(set(data), self.orgs)
+        self._assert_inactive()
+
+    def test_deactivate_enrollments_unauthorized(self):
+        self._assert_active()
+        response = self._submit_unenroll(self.user, self.user.username)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self._assert_active()
+
+    def test_deactivate_enrollments_no_username(self):
+        self._assert_active()
+        response = self._submit_unenroll(self.superuser, "")
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+        data = json.loads(response.content)
+        self.assertEqual(data['message'], 'The user was not specified.')
+        self._assert_active()
+
+    def test_deactivate_enrollments_invalid_username(self):
+        self._assert_active()
+        response = self._submit_unenroll(self.superuser, "a made up username")
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+        data = json.loads(response.content)
+        self.assertEqual(data['message'], 'The user "a made up username" does not exist.')
+        self._assert_active()
+
+    def test_deactivate_enrollments_called_twice(self):
+        self._assert_active()
+        response = self._submit_unenroll(self.superuser, self.user.username)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        response = self._submit_unenroll(self.superuser, self.user.username)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.content, "")
+        self._assert_inactive()
+
+    def _assert_active(self):
+        for course in self.courses:
+            self.assertTrue(CourseEnrollment.is_enrolled(self.user, course.id))
+            _, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, course.id)
+            self.assertTrue(is_active)
+
+    def _assert_inactive(self):
+        for course in self.courses:
+            _, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, course.id)
+            self.assertFalse(is_active)
+
+    def _submit_unenroll(self, submitting_user, unenrolling_username):
+        data = {'user': unenrolling_username}
+        url = reverse('unenrollment')
+        headers = self.build_jwt_headers(submitting_user)
+        return self.client.post(url, json.dumps(data), content_type='application/json', **headers)
diff --git a/common/djangoapps/enrollment/urls.py b/common/djangoapps/enrollment/urls.py
index 9e318a8052f..41618429a2a 100644
--- a/common/djangoapps/enrollment/urls.py
+++ b/common/djangoapps/enrollment/urls.py
@@ -5,7 +5,7 @@ URLs for the Enrollment API
 from django.conf import settings
 from django.conf.urls import url
 
-from .views import EnrollmentCourseDetailView, EnrollmentListView, EnrollmentView
+from .views import EnrollmentCourseDetailView, EnrollmentListView, EnrollmentView, UnenrollmentView
 
 urlpatterns = [
     url(r'^enrollment/{username},{course_key}$'.format(
@@ -17,4 +17,5 @@ urlpatterns = [
     url(r'^enrollment$', EnrollmentListView.as_view(), name='courseenrollments'),
     url(r'^course/{course_key}$'.format(course_key=settings.COURSE_ID_PATTERN),
         EnrollmentCourseDetailView.as_view(), name='courseenrollmentdetails'),
+    url(r'^unenroll$', UnenrollmentView.as_view(), name='unenrollment'),
 ]
diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py
index 01a7e8b6ced..096b576c29e 100644
--- a/common/djangoapps/enrollment/views.py
+++ b/common/djangoapps/enrollment/views.py
@@ -10,7 +10,7 @@ from django.utils.decorators import method_decorator
 from edx_rest_framework_extensions.authentication import JwtAuthentication
 from opaque_keys import InvalidKeyError
 from opaque_keys.edx.keys import CourseKey
-from rest_framework import status
+from rest_framework import status, permissions
 from rest_framework.response import Response
 from rest_framework.throttling import UserRateThrottle
 from rest_framework.views import APIView
@@ -22,6 +22,7 @@ from enrollment.errors import CourseEnrollmentError, CourseEnrollmentExistsError
 from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
 from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain
 from openedx.core.djangoapps.embargo import api as embargo_api
+from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser
 from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
 from openedx.core.lib.api.authentication import (
     OAuth2AuthenticationAllowInactiveUser,
@@ -301,6 +302,75 @@ class EnrollmentCourseDetailView(APIView):
             )
 
 
+class UnenrollmentView(APIView):
+    """
+        **Use Cases**
+
+            * Unenroll a single user from all courses.
+
+              This command can only be issued by a privileged service user.
+
+        **Example Requests**
+
+            POST /api/enrollment/v1/enrollment {
+                "user": "username12345"
+            }
+
+            **POST Parameters**
+
+              A POST request must include the following parameter.
+
+              * user: The username of the user being unenrolled.
+              This will never match the username from the request,
+              since the request is issued as a privileged service user.
+
+        **POST Response Values**
+
+             If the user does not exist, or the user is already unenrolled
+             from all courses, the request returns an HTTP 404 "Does Not Exist"
+             response.
+
+             If an unexpected error occurs, the request returns an HTTP 500 response.
+
+            If the request is successful, an HTTP 200 "OK" response is
+            returned along with a list of all courses from which the user was unenrolled.
+        """
+    authentication_classes = (JwtAuthentication,)
+    permission_classes = (permissions.IsAuthenticated, CanRetireUser)
+
+    def post(self, request):
+        """
+        Unenrolls the specified user from all courses.
+        """
+        # Get the User from the request.
+        username = request.data.get('user', None)
+        if not username:
+            return Response(
+                status=status.HTTP_404_NOT_FOUND,
+                data={
+                    'message': u'The user was not specified.'
+                }
+            )
+        try:
+            # make sure the specified user exists
+            User.objects.get(username=username)
+        except ObjectDoesNotExist:
+            return Response(
+                status=status.HTTP_404_NOT_FOUND,
+                data={
+                    'message': u'The user "{}" does not exist.'.format(username)
+                }
+            )
+        try:
+            enrollments = api.get_enrollments(username)
+            active_enrollments = [enrollment for enrollment in enrollments if enrollment['is_active']]
+            if len(active_enrollments) < 1:
+                return Response(status=status.HTTP_200_OK)
+            return Response(api.unenroll_user_from_all_courses(username))
+        except Exception as exc:  # pylint: disable=broad-except
+            return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
 @can_disable_rate_limit
 class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
     """
-- 
GitLab