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