Add POST method for program enrollments
This commit is contained in:
@@ -7,8 +7,8 @@ from uuid import uuid4
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.api.v1.serializers import ProgramEnrollmentSerializer
|
||||
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@@ -36,5 +36,11 @@ class ProgramEnrollmentSerializerTests(TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
set(data.keys()),
|
||||
{'user', 'external_user_key', 'program_uuid', 'curriculum_uuid', 'status'}
|
||||
set([
|
||||
'user',
|
||||
'external_user_key',
|
||||
'program_uuid',
|
||||
'curriculum_uuid',
|
||||
'status'
|
||||
])
|
||||
)
|
||||
|
||||
@@ -3,13 +3,16 @@ Unit tests for ProgramEnrollment views.
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
import mock
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
from six import text_type
|
||||
@@ -558,3 +561,208 @@ class ProgramCourseEnrollmentListTest(ListViewTestMixin, APITestCase):
|
||||
assert next_response.data['previous'] is not None
|
||||
assert self.get_url(self.program_uuid, self.course_id) in next_response.data['previous']
|
||||
assert '?cursor=' in next_response.data['previous']
|
||||
|
||||
|
||||
class ProgramEnrollmentViewPostTests(APITestCase):
|
||||
"""
|
||||
Tests for the ProgramEnrollment view POST method.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(ProgramEnrollmentViewPostTests, self).setUp()
|
||||
global_staff = GlobalStaffFactory.create(username='global-staff', password='password')
|
||||
self.client.login(username=global_staff.username, password='password')
|
||||
|
||||
def student_enrollment(self, enrollment_status, external_user_key=None):
|
||||
return {
|
||||
'status': enrollment_status,
|
||||
'external_user_key': external_user_key or str(uuid4().hex[0:10]),
|
||||
'curriculum_uuid': str(uuid4())
|
||||
}
|
||||
|
||||
def test_successful_program_enrollments_no_existing_user(self):
|
||||
program_key = uuid4()
|
||||
statuses = ['pending', 'enrolled', 'pending']
|
||||
external_user_keys = ['abc1', 'efg2', 'hij3']
|
||||
|
||||
curriculum_uuid = uuid4()
|
||||
curriculum_uuids = [curriculum_uuid, curriculum_uuid, uuid4()]
|
||||
post_data = [
|
||||
{
|
||||
'external_user_key': e,
|
||||
'status': s,
|
||||
'curriculum_uuid': str(c)
|
||||
}
|
||||
for e, s, c in zip(external_user_keys, statuses, curriculum_uuids)
|
||||
]
|
||||
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[program_key])
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
for i in range(3):
|
||||
enrollment = ProgramEnrollment.objects.filter(external_user_key=external_user_keys[i])[0]
|
||||
|
||||
self.assertEqual(enrollment.external_user_key, external_user_keys[i])
|
||||
self.assertEqual(enrollment.program_uuid, program_key)
|
||||
self.assertEqual(enrollment.status, statuses[i])
|
||||
self.assertEqual(enrollment.curriculum_uuid, curriculum_uuids[i])
|
||||
self.assertEqual(enrollment.user, None)
|
||||
|
||||
def test_successful_program_enrollments_existing_user(self):
|
||||
program_key = uuid4()
|
||||
curriculum_uuid = uuid4()
|
||||
|
||||
post_data = [
|
||||
{
|
||||
'status': 'enrolled',
|
||||
'external_user_key': 'abc1',
|
||||
'curriculum_uuid': str(curriculum_uuid)
|
||||
}
|
||||
]
|
||||
|
||||
user = User.objects.create_user('test_user', 'test@example.com', 'password')
|
||||
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[program_key])
|
||||
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=user
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
enrollment = ProgramEnrollment.objects.first()
|
||||
|
||||
self.assertEqual(enrollment.external_user_key, 'abc1')
|
||||
self.assertEqual(enrollment.program_uuid, program_key)
|
||||
self.assertEqual(enrollment.status, 'enrolled')
|
||||
self.assertEqual(enrollment.curriculum_uuid, curriculum_uuid)
|
||||
self.assertEqual(enrollment.user, user)
|
||||
|
||||
def test_enrollment_payload_limit(self):
|
||||
|
||||
post_data = []
|
||||
for _ in range(26):
|
||||
post_data += self.student_enrollment('enrolled')
|
||||
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 413)
|
||||
|
||||
def test_duplicate_enrollment(self):
|
||||
post_data = [
|
||||
self.student_enrollment('enrolled', '001'),
|
||||
self.student_enrollment('enrolled', '002'),
|
||||
self.student_enrollment('enrolled', '001'),
|
||||
]
|
||||
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 207)
|
||||
self.assertEqual(response.data, {
|
||||
'001': 'duplicated',
|
||||
'002': 'enrolled',
|
||||
})
|
||||
|
||||
def test_unprocessable_enrollment(self):
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
response = self.client.post(
|
||||
url,
|
||||
json.dumps([{'status': 'enrolled'}]),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_unauthenticated(self):
|
||||
self.client.logout()
|
||||
post_data = [
|
||||
self.student_enrollment('enrolled')
|
||||
]
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
response = self.client.post(
|
||||
url,
|
||||
json.dumps(post_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_program_unauthorized(self):
|
||||
student = UserFactory.create(username='student', password='password')
|
||||
self.client.login(username=student.username, password='password')
|
||||
|
||||
post_data = [
|
||||
self.student_enrollment('enrolled')
|
||||
]
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
response = self.client.post(
|
||||
url,
|
||||
json.dumps(post_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_program_not_found(self):
|
||||
post_data = [
|
||||
self.student_enrollment('enrolled')
|
||||
]
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
response = self.client.post(
|
||||
url,
|
||||
json.dumps(post_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_partially_valid_enrollment(self):
|
||||
|
||||
post_data = [
|
||||
self.student_enrollment('new', '001'),
|
||||
self.student_enrollment('pending', '003'),
|
||||
]
|
||||
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 207)
|
||||
self.assertEqual(response.data, {
|
||||
'001': 'invalid-status',
|
||||
'003': 'pending',
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ ProgramEnrollment Views
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from collections import Counter, OrderedDict
|
||||
from functools import wraps
|
||||
|
||||
from django.http import Http404
|
||||
@@ -21,8 +22,11 @@ from lms.djangoapps.program_enrollments.api.v1.serializers import (
|
||||
ProgramCourseEnrollmentListSerializer,
|
||||
ProgramCourseEnrollmentRequestSerializer,
|
||||
ProgramEnrollmentListSerializer,
|
||||
ProgramEnrollmentSerializer,
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.utils import get_user_by_program_id
|
||||
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
|
||||
@@ -120,6 +124,73 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
|
||||
],
|
||||
}
|
||||
|
||||
Create
|
||||
==========
|
||||
Path: `/api/program_enrollments/v1/programs/{program_uuid}/enrollments/`
|
||||
Where the program_uuid will be the uuid for a program.
|
||||
|
||||
Request body:
|
||||
* The request body will be a list of one or more students to enroll with the following schema:
|
||||
{
|
||||
'status': A choice of the following statuses: ['enrolled', 'pending', 'withdrawn', 'suspended'],
|
||||
'external_user_key': string representation of a learner in partner systems,
|
||||
'curriculum_uuid': string representation of a curriculum
|
||||
}
|
||||
Example:
|
||||
[
|
||||
{
|
||||
"status": "enrolled",
|
||||
"external_user_key": "123",
|
||||
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
|
||||
},{
|
||||
"status": "withdrawn",
|
||||
"external_user_key": "456",
|
||||
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
|
||||
},{
|
||||
"status": "pending",
|
||||
"external_user_key": "789",
|
||||
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
|
||||
},{
|
||||
"status": "suspended",
|
||||
"external_user_key": "abc",
|
||||
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
|
||||
},
|
||||
]
|
||||
|
||||
Returns:
|
||||
* Response Body: {<external_user_key>: <status>} with as many keys as there were in the request body
|
||||
* external_user_key - string representation of a learner in partner systems
|
||||
* status - the learner's registration status
|
||||
* success statuses:
|
||||
* 'enrolled'
|
||||
* 'pending'
|
||||
* 'withdrawn'
|
||||
* 'suspended'
|
||||
* failure statuses:
|
||||
* 'duplicated' - the request body listed the same learner twice
|
||||
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
|
||||
* 'invalid-status' - a status other than 'enrolled', 'pending', 'withdrawn', 'suspended' was entered
|
||||
* 201: CREATED - All students were successfully enrolled.
|
||||
* Example json response:
|
||||
{
|
||||
'123': 'enrolled',
|
||||
'456': 'pending',
|
||||
'789': 'withdrawn,
|
||||
'abc': 'suspended'
|
||||
}
|
||||
* 207: MULTI-STATUS - Some students were successfully enrolled while others were not.
|
||||
Details are included in the JSON response data.
|
||||
* Example json response:
|
||||
{
|
||||
'123': 'duplicated',
|
||||
'456': 'conflict',
|
||||
'789': 'invalid-status,
|
||||
'abc': 'suspended'
|
||||
}
|
||||
* 403: FORBIDDEN - The requesting user lacks access to enroll students in the given program.
|
||||
* 404: NOT FOUND - The requested program does not exist.
|
||||
* 413: PAYLOAD TOO LARGE - Over 25 students supplied
|
||||
* 422: Unprocesable Entity - None of the students were successfully listed.
|
||||
"""
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
@@ -139,6 +210,89 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
|
||||
serializer = ProgramEnrollmentListSerializer(paginated_enrollments, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@verify_program_exists
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
This is the POST for ProgramEnrollments
|
||||
"""
|
||||
if len(request.data) > 25:
|
||||
return Response(
|
||||
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
program_uuid = kwargs['program_uuid']
|
||||
student_data = OrderedDict((
|
||||
row.get('external_user_key'),
|
||||
{
|
||||
'program_uuid': program_uuid,
|
||||
'curriculum_uuid': row.get('curriculum_uuid'),
|
||||
'status': row.get('status'),
|
||||
'external_user_key': row.get('external_user_key'),
|
||||
})
|
||||
for row in request.data
|
||||
)
|
||||
|
||||
key_counter = Counter([enrollment.get('external_user_key') for enrollment in request.data])
|
||||
|
||||
response_data = {}
|
||||
for student_key, count in key_counter.items():
|
||||
if count > 1:
|
||||
response_data[student_key] = CourseEnrollmentResponseStatuses.DUPLICATED
|
||||
student_data.pop(student_key)
|
||||
|
||||
existing_enrollments = ProgramEnrollment.bulk_read_by_student_key(program_uuid, student_data)
|
||||
for enrollment in existing_enrollments:
|
||||
response_data[enrollment.external_user_key] = CourseEnrollmentResponseStatuses.CONFLICT
|
||||
student_data.pop(enrollment.external_user_key)
|
||||
|
||||
enrollments_to_create = {}
|
||||
|
||||
for student_key, data in student_data.items():
|
||||
curriculum_uuid = data['curriculum_uuid']
|
||||
existing_user = get_user_by_program_id(student_key, program_uuid)
|
||||
|
||||
if existing_user:
|
||||
data['user'] = existing_user.id
|
||||
|
||||
serializer = ProgramEnrollmentSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
enrollments_to_create[(student_key, curriculum_uuid)] = serializer
|
||||
response_data[student_key] = data.get('status')
|
||||
else:
|
||||
if 'status' in serializer.errors and serializer.errors['status'][0].code == 'invalid_choice':
|
||||
response_data[student_key] = CourseEnrollmentResponseStatuses.INVALID_STATUS
|
||||
else:
|
||||
return Response(
|
||||
'invalid enrollment record',
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
)
|
||||
|
||||
for enrollment_serializer in enrollments_to_create.values():
|
||||
# create the model
|
||||
enrollment_serializer.save()
|
||||
# TODO: make this a bulk save
|
||||
|
||||
if not enrollments_to_create:
|
||||
return Response(
|
||||
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
data=response_data,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
if len(request.data) != len(enrollments_to_create):
|
||||
return Response(
|
||||
status=status.HTTP_207_MULTI_STATUS,
|
||||
data=response_data,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_201_CREATED,
|
||||
data=response_data,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
|
||||
class ProgramSpecificViewMixin(object):
|
||||
"""
|
||||
|
||||
@@ -60,6 +60,19 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
|
||||
if not (self.user or self.external_user_key):
|
||||
raise ValidationError(_('One of user or external_user_key must not be null.'))
|
||||
|
||||
@classmethod
|
||||
def bulk_read_by_student_key(cls, program_uuid, student_data):
|
||||
"""
|
||||
args:
|
||||
program_uuid - The UUID of the program to read enrollment data of.
|
||||
student_data - A dictionary keyed by external_user_key and
|
||||
valued by a dict containing the curriculum_uuid for the user in the given program.
|
||||
"""
|
||||
return cls.objects.filter(
|
||||
program_uuid=program_uuid,
|
||||
external_user_key__in=student_data.keys(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def retire_user(cls, user_id):
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,47 @@ class ProgramEnrollmentModelTests(TestCase):
|
||||
status='enrolled'
|
||||
)
|
||||
|
||||
def test_bulk_read_by_student_key(self):
|
||||
curriculum_a = uuid4()
|
||||
curriculum_b = uuid4()
|
||||
enrollments = []
|
||||
student_data = {}
|
||||
|
||||
for i in xrange(5):
|
||||
# This will give us 4 program enrollments for self.program_uuid
|
||||
# and 1 enrollment for self.other_program_uuid
|
||||
user_curriculum = curriculum_b if i % 2 else curriculum_a
|
||||
user_status = 'pending' if i % 2 else 'enrolled'
|
||||
user_program = self.other_program_uuid if i == 4 else self.program_uuid
|
||||
user_key = 'student-{}'.format(i)
|
||||
enrollments.append(
|
||||
ProgramEnrollment.objects.create(
|
||||
user=None,
|
||||
external_user_key=user_key,
|
||||
program_uuid=user_program,
|
||||
curriculum_uuid=user_curriculum,
|
||||
status=user_status,
|
||||
)
|
||||
)
|
||||
student_data[user_key] = {'curriculum_uuid': user_curriculum}
|
||||
|
||||
enrollment_records = ProgramEnrollment.bulk_read_by_student_key(self.program_uuid, student_data)
|
||||
|
||||
expected = {
|
||||
'student-0': {'curriculum_uuid': curriculum_a, 'status': 'enrolled', 'program_uuid': self.program_uuid},
|
||||
'student-1': {'curriculum_uuid': curriculum_b, 'status': 'pending', 'program_uuid': self.program_uuid},
|
||||
'student-2': {'curriculum_uuid': curriculum_a, 'status': 'enrolled', 'program_uuid': self.program_uuid},
|
||||
'student-3': {'curriculum_uuid': curriculum_b, 'status': 'pending', 'program_uuid': self.program_uuid},
|
||||
}
|
||||
assert expected == {
|
||||
enrollment.external_user_key: {
|
||||
'curriculum_uuid': enrollment.curriculum_uuid,
|
||||
'status': enrollment.status,
|
||||
'program_uuid': enrollment.program_uuid,
|
||||
}
|
||||
for enrollment in enrollment_records
|
||||
}
|
||||
|
||||
def test_user_retirement(self):
|
||||
"""
|
||||
Test that the external_user_key is uccessfully retired for a user's program enrollments and history.
|
||||
|
||||
Reference in New Issue
Block a user