Create API endpoint to unenroll user from all courses; EDUCATOR-2603
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user