Create API endpoint to unenroll user from all courses; EDUCATOR-2603

This commit is contained in:
Sanford Student
2018-04-04 12:13:09 -04:00
parent 1bfad94245
commit 9526bbc9eb
5 changed files with 220 additions and 9 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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'),
]

View File

@@ -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):
"""