Add put endpoint for program enrollments, rework program enrollment view
This commit is contained in:
@@ -10,29 +10,48 @@ MAX_ENROLLMENT_RECORDS = 25
|
||||
REQUEST_STUDENT_KEY = 'student_key'
|
||||
|
||||
|
||||
class CourseEnrollmentResponseStatuses(object):
|
||||
class BaseEnrollmentResponseStatuses(object):
|
||||
"""
|
||||
Class to group response statuses returned by the course enrollment endpoint
|
||||
Class to group common response statuses
|
||||
"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
DUPLICATED = "duplicated"
|
||||
DUPLICATED = 'duplicated'
|
||||
INVALID_STATUS = "invalid-status"
|
||||
CONFLICT = "conflict"
|
||||
ILLEGAL_OPERATION = "illegal-operation"
|
||||
NOT_IN_PROGRAM = "not-in-program"
|
||||
NOT_FOUND = "not-found"
|
||||
INTERNAL_ERROR = "internal-error"
|
||||
|
||||
ERROR_STATUSES = (
|
||||
ERROR_STATUSES = {
|
||||
DUPLICATED,
|
||||
INVALID_STATUS,
|
||||
CONFLICT,
|
||||
ILLEGAL_OPERATION,
|
||||
NOT_IN_PROGRAM,
|
||||
NOT_FOUND,
|
||||
INTERNAL_ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class CourseEnrollmentResponseStatuses(BaseEnrollmentResponseStatuses):
|
||||
"""
|
||||
Class to group response statuses returned by the course enrollment endpoint
|
||||
"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
NOT_FOUND = "not-found"
|
||||
|
||||
ERROR_STATUSES = BaseEnrollmentResponseStatuses.ERROR_STATUSES | {NOT_FOUND}
|
||||
|
||||
|
||||
class ProgramEnrollmentResponseStatuses(BaseEnrollmentResponseStatuses):
|
||||
"""
|
||||
Class to group response statuses returned by the program enrollment endpoint
|
||||
"""
|
||||
ENROLLED = 'enrolled'
|
||||
PENDING = 'pending'
|
||||
SUSPENDED = 'suspended'
|
||||
CANCELED = 'canceled'
|
||||
|
||||
VALID_STATUSES = [ENROLLED, PENDING, SUSPENDED, CANCELED]
|
||||
|
||||
|
||||
class CourseRunProgressStatuses(object):
|
||||
|
||||
@@ -7,11 +7,31 @@ from rest_framework import serializers
|
||||
from six import text_type
|
||||
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.api.v1.constants import CourseRunProgressStatuses
|
||||
from lms.djangoapps.program_enrollments.api.v1.constants import (
|
||||
CourseRunProgressStatuses,
|
||||
ProgramEnrollmentResponseStatuses
|
||||
)
|
||||
|
||||
|
||||
class InvalidStatusMixin(object):
|
||||
"""
|
||||
Mixin to provide has_invalid_status method
|
||||
"""
|
||||
def has_invalid_status(self):
|
||||
"""
|
||||
Returns whether or not this serializer has an invalid error choice on the "status" field
|
||||
"""
|
||||
try:
|
||||
for status_error in self.errors['status']:
|
||||
if status_error.code == 'invalid_choice':
|
||||
return True
|
||||
except KeyError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class ProgramEnrollmentSerializer(serializers.ModelSerializer):
|
||||
class ProgramEnrollmentSerializer(serializers.ModelSerializer, InvalidStatusMixin):
|
||||
"""
|
||||
Serializer for Program Enrollments
|
||||
"""
|
||||
@@ -37,6 +57,31 @@ class ProgramEnrollmentSerializer(serializers.ModelSerializer):
|
||||
return ProgramEnrollment.objects.create(**validated_data)
|
||||
|
||||
|
||||
class BaseProgramEnrollmentRequestMixin(serializers.Serializer, InvalidStatusMixin):
|
||||
"""
|
||||
Base fields for all program enrollment related serializers
|
||||
"""
|
||||
student_key = serializers.CharField()
|
||||
status = serializers.ChoiceField(
|
||||
allow_blank=False,
|
||||
choices=ProgramEnrollmentResponseStatuses.VALID_STATUSES
|
||||
)
|
||||
|
||||
|
||||
class ProgramEnrollmentCreateRequestSerializer(BaseProgramEnrollmentRequestMixin):
|
||||
"""
|
||||
Serializer for program enrollment creation requests
|
||||
"""
|
||||
curriculum_uuid = serializers.UUIDField()
|
||||
|
||||
|
||||
class ProgramEnrollmentModifyRequestSerializer(BaseProgramEnrollmentRequestMixin):
|
||||
"""
|
||||
Serializer for program enrollment modification requests
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ProgramEnrollmentListSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for listing enrollments in a program.
|
||||
@@ -53,23 +98,6 @@ class ProgramEnrollmentListSerializer(serializers.Serializer):
|
||||
return bool(obj.user)
|
||||
|
||||
|
||||
class InvalidStatusMixin(object):
|
||||
"""
|
||||
Mixin to provide has_invalid_status method
|
||||
"""
|
||||
def has_invalid_status(self):
|
||||
"""
|
||||
Returns whether or not this serializer has an invalid error choice on the "status" field
|
||||
"""
|
||||
try:
|
||||
for status_error in self.errors['status']:
|
||||
if status_error.code == 'invalid_choice':
|
||||
return True
|
||||
except KeyError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class ProgramCourseEnrollmentRequestSerializer(serializers.Serializer, InvalidStatusMixin):
|
||||
"""
|
||||
|
||||
@@ -28,6 +28,7 @@ from lms.djangoapps.program_enrollments.api.v1.constants import (
|
||||
CourseEnrollmentResponseStatuses as CourseStatuses,
|
||||
CourseRunProgressStatuses,
|
||||
MAX_ENROLLMENT_RECORDS,
|
||||
ProgramEnrollmentResponseStatuses as ProgramStatuses,
|
||||
REQUEST_STUDENT_KEY,
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
|
||||
@@ -801,12 +802,112 @@ class ProgramCourseEnrollmentListTest(ListViewTestMixin, APITestCase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ProgramEnrollmentViewPostTests(APITestCase):
|
||||
class BaseProgramEnrollmentWriteTestsMixin(object):
|
||||
""" Mixin class that defines common tests for program enrollment write endpoints """
|
||||
add_uuid = False
|
||||
|
||||
def student_enrollment(self, enrollment_status, external_user_key=None, prepare_student=False):
|
||||
""" Convenience method to create a student enrollment record """
|
||||
enrollment = {
|
||||
REQUEST_STUDENT_KEY: external_user_key or str(uuid4().hex[0:10]),
|
||||
'status': enrollment_status,
|
||||
}
|
||||
if self.add_uuid:
|
||||
enrollment['curriculum_uuid'] = str(uuid4())
|
||||
if prepare_student:
|
||||
self.prepare_student(enrollment)
|
||||
return enrollment
|
||||
|
||||
def prepare_student(self, enrollment):
|
||||
pass
|
||||
|
||||
def get_url(self, program_uuid=None):
|
||||
if program_uuid is None:
|
||||
program_uuid = uuid4()
|
||||
return reverse('programs_api:v1:program_enrollments', args=[program_uuid])
|
||||
|
||||
def test_unauthenticated(self):
|
||||
self.client.logout()
|
||||
request_data = [self.student_enrollment('enrolled')]
|
||||
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_enrollment_payload_limit(self):
|
||||
request_data = [self.student_enrollment('enrolled') for _ in range(MAX_ENROLLMENT_RECORDS + 1)]
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(response.status_code, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
def test_duplicate_enrollment(self):
|
||||
request_data = [
|
||||
self.student_enrollment('enrolled', '001'),
|
||||
self.student_enrollment('enrolled', '001'),
|
||||
]
|
||||
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
self.assertEqual(response.data, {'001': 'duplicated'})
|
||||
|
||||
def test_unprocessable_enrollment(self):
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.request(
|
||||
self.get_url(),
|
||||
json.dumps([{'status': 'enrolled'}]),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
self.assertEqual(response.data, 'invalid enrollment record')
|
||||
|
||||
def test_program_unauthorized(self):
|
||||
student = UserFactory.create(password='password')
|
||||
self.client.login(username=student.username, password='password')
|
||||
|
||||
request_data = [self.student_enrollment('enrolled')]
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_program_not_found(self):
|
||||
post_data = [self.student_enrollment('enrolled')]
|
||||
nonexistant_uuid = uuid4()
|
||||
response = self.request(
|
||||
self.get_url(program_uuid=nonexistant_uuid),
|
||||
json.dumps(post_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@ddt.data(
|
||||
[{'status': 'pending'}],
|
||||
[{'status': 'not-a-status'}],
|
||||
[{'status': 'pending'}, {'status': 'pending'}],
|
||||
)
|
||||
def test_no_student_key(self, bad_records):
|
||||
program_uuid = uuid4()
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[program_uuid])
|
||||
enrollments = [self.student_enrollment('enrolled', '001', True)]
|
||||
enrollments.extend(bad_records)
|
||||
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.request(url, json.dumps(enrollments), content_type='application/json')
|
||||
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ProgramEnrollmentViewPostTests(BaseProgramEnrollmentWriteTestsMixin, APITestCase):
|
||||
"""
|
||||
Tests for the ProgramEnrollment view POST method.
|
||||
"""
|
||||
add_uuid = True
|
||||
success_status = status.HTTP_201_CREATED
|
||||
|
||||
def setUp(self):
|
||||
super(ProgramEnrollmentViewPostTests, self).setUp()
|
||||
self.request = self.client.post
|
||||
global_staff = GlobalStaffFactory.create(username='global-staff', password='password')
|
||||
self.client.login(username=global_staff.username, password='password')
|
||||
|
||||
@@ -814,13 +915,6 @@ class ProgramEnrollmentViewPostTests(APITestCase):
|
||||
super(ProgramEnrollmentViewPostTests, self).tearDown()
|
||||
ProgramEnrollment.objects.all().delete()
|
||||
|
||||
def student_enrollment(self, enrollment_status, external_user_key=None):
|
||||
return {
|
||||
REQUEST_STUDENT_KEY: external_user_key or str(uuid4().hex[0:10]),
|
||||
'status': enrollment_status,
|
||||
'curriculum_uuid': str(uuid4())
|
||||
}
|
||||
|
||||
def test_successful_program_enrollments_no_existing_user(self):
|
||||
program_key = uuid4()
|
||||
statuses = ['pending', 'enrolled', 'pending']
|
||||
@@ -923,189 +1017,18 @@ class ProgramEnrollmentViewPostTests(APITestCase):
|
||||
self.assertEqual(enrollment.curriculum_uuid, curriculum_uuid)
|
||||
self.assertIsNone(enrollment.user)
|
||||
|
||||
def test_enrollment_payload_limit(self):
|
||||
|
||||
post_data = []
|
||||
for _ in range(MAX_ENROLLMENT_RECORDS + 1):
|
||||
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, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
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, status.HTTP_207_MULTI_STATUS)
|
||||
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, 400)
|
||||
self.assertEqual(response.data, 'invalid enrollment record')
|
||||
|
||||
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, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
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, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
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, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
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, status.HTTP_207_MULTI_STATUS)
|
||||
self.assertEqual(response.data, {
|
||||
'001': 'invalid-status',
|
||||
'003': 'pending',
|
||||
})
|
||||
|
||||
@ddt.data(REQUEST_STUDENT_KEY, 'status', 'curriculum_uuid')
|
||||
def test_missing_field(self, removed_field):
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
enrollments = [
|
||||
self.student_enrollment('enrolled') for _ in range(3)
|
||||
]
|
||||
enrollments[2].pop(removed_field)
|
||||
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(enrollments), content_type='application/json')
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
|
||||
@ddt.data(REQUEST_STUDENT_KEY, 'status', 'curriculum_uuid')
|
||||
def test_none_field(self, none_field):
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
enrollments = [
|
||||
self.student_enrollment('enrolled') for _ in range(3)
|
||||
]
|
||||
enrollments[2][none_field] = None
|
||||
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(enrollments), content_type='application/json')
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
|
||||
@ddt.data(
|
||||
[{'status': 'pending'}],
|
||||
[{'status': 'not-a-status'}],
|
||||
[{'status': 'pending'}, {'status': 'pending'}],
|
||||
)
|
||||
def test_no_student_key(self, bad_records):
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
enrollments = [self.student_enrollment('enrolled')]
|
||||
enrollments.extend(bad_records)
|
||||
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(enrollments), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ProgramEnrollmentViewPatchTests(APITestCase):
|
||||
class ProgramEnrollmentViewPatchTests(BaseProgramEnrollmentWriteTestsMixin, APITestCase):
|
||||
"""
|
||||
Tests for the ProgramEnrollment view PATCH method.
|
||||
"""
|
||||
add_uuid = False
|
||||
success_status = status.HTTP_200_OK
|
||||
|
||||
def setUp(self):
|
||||
super(ProgramEnrollmentViewPatchTests, self).setUp()
|
||||
self.request = self.client.patch
|
||||
|
||||
self.program_uuid = '00000000-1111-2222-3333-444444444444'
|
||||
self.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
|
||||
@@ -1120,11 +1043,14 @@ class ProgramEnrollmentViewPatchTests(APITestCase):
|
||||
|
||||
self.client.login(username=self.global_staff.username, password=self.password)
|
||||
|
||||
def student_enrollment(self, enrollment_status, external_user_key=None):
|
||||
return {
|
||||
'status': enrollment_status,
|
||||
REQUEST_STUDENT_KEY: external_user_key or str(uuid4().hex[0:10]),
|
||||
}
|
||||
def prepare_student(self, enrollment):
|
||||
ProgramEnrollment.objects.create(
|
||||
program_uuid=self.program_uuid,
|
||||
curriculum_uuid=self.curriculum_uuid,
|
||||
user=None,
|
||||
status='pending',
|
||||
external_user_key=enrollment[REQUEST_STUDENT_KEY],
|
||||
)
|
||||
|
||||
def test_successfully_patched_program_enrollment(self):
|
||||
enrollments = {}
|
||||
@@ -1169,71 +1095,7 @@ class ProgramEnrollmentViewPatchTests(APITestCase):
|
||||
assert status.HTTP_200_OK == response.status_code
|
||||
assert expected_response == response.data
|
||||
|
||||
def test_enrollment_payload_limit(self):
|
||||
patch_data = []
|
||||
for _ in range(MAX_ENROLLMENT_RECORDS + 1):
|
||||
patch_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):
|
||||
response = self.client.patch(url, json.dumps(patch_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
def test_unauthenticated(self):
|
||||
self.client.logout()
|
||||
patch_data = [
|
||||
self.student_enrollment('enrolled')
|
||||
]
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
response = self.client.patch(
|
||||
url,
|
||||
json.dumps(patch_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_program_unauthorized(self):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
|
||||
patch_data = [
|
||||
self.student_enrollment('enrolled')
|
||||
]
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
response = self.client.patch(
|
||||
url,
|
||||
json.dumps(patch_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_program_not_found(self):
|
||||
patch_data = [
|
||||
self.student_enrollment('enrolled')
|
||||
]
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[uuid4()])
|
||||
response = self.client.patch(
|
||||
url,
|
||||
json.dumps(patch_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
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):
|
||||
response = self.client.patch(
|
||||
url,
|
||||
json.dumps([{'status': 'enrolled'}]),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data, 'invalid enrollment record')
|
||||
|
||||
def test_duplicate_enrollment(self):
|
||||
def test_duplicate_enrollment_record_changed(self):
|
||||
enrollments = {}
|
||||
for i in range(4):
|
||||
user_key = 'user-{}'.format(i)
|
||||
@@ -1274,7 +1136,7 @@ class ProgramEnrollmentViewPatchTests(APITestCase):
|
||||
'user-2': 'enrolled',
|
||||
})
|
||||
|
||||
def test_partially_valid_enrollment(self):
|
||||
def test_partially_valid_enrollment_record_changed(self):
|
||||
enrollments = {}
|
||||
for i in range(4):
|
||||
user_key = 'user-{}'.format(i)
|
||||
@@ -1316,26 +1178,89 @@ class ProgramEnrollmentViewPatchTests(APITestCase):
|
||||
'user-who-is-not-in-program': 'not-in-program',
|
||||
})
|
||||
|
||||
@ddt.data(
|
||||
[{'status': 'pending'}],
|
||||
[{'status': 'not-a-status'}],
|
||||
[{'status': 'pending'}, {'status': 'pending'}],
|
||||
)
|
||||
def test_no_student_key(self, bad_records):
|
||||
program_uuid = uuid4()
|
||||
url = reverse('programs_api:v1:program_enrollments', args=[program_uuid])
|
||||
enrollments = [self.student_enrollment('enrolled')]
|
||||
ProgramEnrollmentFactory.create(
|
||||
external_user_key=enrollments[0]['student_key'],
|
||||
program_uuid=program_uuid
|
||||
|
||||
@ddt.ddt
|
||||
class ProgramEnrollmentViewPutTests(BaseProgramEnrollmentWriteTestsMixin, APITestCase):
|
||||
"""
|
||||
Tests for the ProgramEnrollment view PATCH method.
|
||||
"""
|
||||
add_uuid = True
|
||||
success_status = status.HTTP_200_OK
|
||||
|
||||
def setUp(self):
|
||||
super(ProgramEnrollmentViewPutTests, self).setUp()
|
||||
self.request = self.client.put
|
||||
|
||||
self.program_uuid = '00000000-1111-2222-3333-444444444444'
|
||||
self.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
|
||||
|
||||
self.global_staff = GlobalStaffFactory.create(username='global-staff', password='password')
|
||||
self.client.login(username=self.global_staff.username, password='password')
|
||||
|
||||
patch_get_user = mock.patch(
|
||||
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
|
||||
autospec=True,
|
||||
return_value=None
|
||||
)
|
||||
enrollments.extend(bad_records)
|
||||
self.mock_get_user = patch_get_user.start()
|
||||
self.addCleanup(patch_get_user.stop)
|
||||
|
||||
def prepare_student(self, enrollment):
|
||||
ProgramEnrollment.objects.create(
|
||||
program_uuid=self.program_uuid,
|
||||
curriculum_uuid=self.curriculum_uuid,
|
||||
user=None,
|
||||
status='pending',
|
||||
external_user_key=enrollment[REQUEST_STUDENT_KEY],
|
||||
)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_all_create_or_modify(self, create_users):
|
||||
request_data = [
|
||||
self.student_enrollment(ProgramStatuses.ENROLLED)
|
||||
for _ in range(5)
|
||||
]
|
||||
if create_users:
|
||||
for enrollment in request_data:
|
||||
ProgramEnrollmentFactory(
|
||||
program_uuid=self.program_uuid,
|
||||
status=ProgramStatuses.PENDING,
|
||||
external_user_key=enrollment[REQUEST_STUDENT_KEY],
|
||||
)
|
||||
|
||||
url = self.get_url(program_uuid=self.program_uuid)
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.client.patch(url, json.dumps(enrollments), content_type='application/json')
|
||||
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
self.assertEqual(5, len(response.data))
|
||||
for response_status in response.data.values():
|
||||
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
def test_half_create_modify(self):
|
||||
request_data = [
|
||||
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-01'),
|
||||
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-02'),
|
||||
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-03'),
|
||||
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-04'),
|
||||
]
|
||||
ProgramEnrollmentFactory(
|
||||
program_uuid=self.program_uuid,
|
||||
status=ProgramStatuses.PENDING,
|
||||
external_user_key='learner-03',
|
||||
)
|
||||
ProgramEnrollmentFactory(
|
||||
program_uuid=self.program_uuid,
|
||||
status=ProgramStatuses.PENDING,
|
||||
external_user_key='learner-04',
|
||||
)
|
||||
|
||||
url = self.get_url(program_uuid=self.program_uuid)
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
|
||||
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
self.assertEqual(4, len(response.data))
|
||||
for response_status in response.data.values():
|
||||
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -5,7 +5,6 @@ ProgramEnrollment Views
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
from collections import Counter, OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
from pytz import UTC
|
||||
@@ -33,14 +32,15 @@ from lms.djangoapps.program_enrollments.api.v1.constants import (
|
||||
CourseEnrollmentResponseStatuses,
|
||||
CourseRunProgressStatuses,
|
||||
MAX_ENROLLMENT_RECORDS,
|
||||
REQUEST_STUDENT_KEY,
|
||||
ProgramEnrollmentResponseStatuses,
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.api.v1.serializers import (
|
||||
CourseRunOverviewListSerializer,
|
||||
ProgramCourseEnrollmentListSerializer,
|
||||
ProgramCourseEnrollmentRequestSerializer,
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
ProgramEnrollmentListSerializer,
|
||||
ProgramEnrollmentSerializer,
|
||||
ProgramEnrollmentModifyRequestSerializer,
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.utils import get_user_by_program_id, ProviderDoesNotExistException
|
||||
@@ -345,150 +345,162 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
|
||||
"""
|
||||
Create program enrollments for a list of learners
|
||||
"""
|
||||
if len(request.data) > MAX_ENROLLMENT_RECORDS:
|
||||
return Response(
|
||||
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
program_uuid = kwargs['program_uuid']
|
||||
student_data = self._request_data_by_student_key(request, program_uuid)
|
||||
if None in student_data:
|
||||
return Response(
|
||||
'invalid enrollment record',
|
||||
status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
response_data = {}
|
||||
response_data.update(self._remove_duplicate_entries(request, student_data))
|
||||
response_data.update(self._remove_existing_entries(program_uuid, student_data))
|
||||
|
||||
enrollments_to_create = {}
|
||||
|
||||
for student_key, data in student_data.items():
|
||||
curriculum_uuid = data['curriculum_uuid']
|
||||
|
||||
try:
|
||||
existing_user = get_user_by_program_id(student_key, program_uuid)
|
||||
if existing_user:
|
||||
data['user'] = existing_user.id
|
||||
except ProviderDoesNotExistException:
|
||||
pass # IDP has not yet been set up, just create waiting enrollments
|
||||
|
||||
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_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# TODO: make this a bulk save - https://openedx.atlassian.net/browse/EDUCATOR-4305
|
||||
for (student_key, _), enrollment_serializer in enrollments_to_create.items():
|
||||
enrollment_serializer.save()
|
||||
|
||||
return self._get_created_or_updated_response(request, enrollments_to_create, response_data)
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
self.create_program_enrollment,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@verify_program_exists
|
||||
def patch(self, request, **kwargs):
|
||||
"""
|
||||
Modify the program enrollments for a list of learners
|
||||
Modify program enrollments for a list of learners
|
||||
"""
|
||||
if len(request.data) > MAX_ENROLLMENT_RECORDS:
|
||||
return Response(
|
||||
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
program_uuid = kwargs['program_uuid']
|
||||
student_data = self._request_data_by_student_key(request, program_uuid)
|
||||
if None in student_data:
|
||||
return Response(
|
||||
'invalid enrollment record',
|
||||
status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
response_data = {}
|
||||
response_data.update(self._remove_duplicate_entries(request, student_data))
|
||||
|
||||
existing_enrollments = {
|
||||
enrollment.external_user_key: enrollment
|
||||
for enrollment in
|
||||
ProgramEnrollment.bulk_read_by_student_key(program_uuid, student_data)
|
||||
}
|
||||
|
||||
enrollments_to_create = {}
|
||||
|
||||
for external_user_key in student_data.keys():
|
||||
if external_user_key not in existing_enrollments:
|
||||
student_data.pop(external_user_key)
|
||||
response_data[external_user_key] = CourseEnrollmentResponseStatuses.NOT_IN_PROGRAM
|
||||
|
||||
for external_user_key, enrollment in existing_enrollments.items():
|
||||
student = {key: value for key, value in student_data[external_user_key].items() if key == 'status'}
|
||||
enrollment_serializer = ProgramEnrollmentSerializer(enrollment, data=student, partial=True)
|
||||
if enrollment_serializer.is_valid():
|
||||
enrollments_to_create[(external_user_key, enrollment.curriculum_uuid)] = enrollment_serializer
|
||||
enrollment_serializer.save()
|
||||
response_data[external_user_key] = student['status']
|
||||
else:
|
||||
serializer_is_invalid = enrollment_serializer.errors['status'][0].code == 'invalid_choice'
|
||||
if 'status' in enrollment_serializer.errors and serializer_is_invalid:
|
||||
response_data[external_user_key] = CourseEnrollmentResponseStatuses.INVALID_STATUS
|
||||
|
||||
return self._get_created_or_updated_response(request, enrollments_to_create, response_data, status.HTTP_200_OK)
|
||||
|
||||
def _remove_duplicate_entries(self, request, student_data):
|
||||
""" Helper method to remove duplicate entries (based on student key) from request data. """
|
||||
result = {}
|
||||
key_counter = Counter([enrollment.get(REQUEST_STUDENT_KEY) for enrollment in request.data])
|
||||
for student_key, count in key_counter.items():
|
||||
if count > 1:
|
||||
result[student_key] = CourseEnrollmentResponseStatuses.DUPLICATED
|
||||
student_data.pop(student_key)
|
||||
return result
|
||||
|
||||
def _request_data_by_student_key(self, request, program_uuid):
|
||||
"""
|
||||
Helper method that returns an OrderedDict of rows from request.data,
|
||||
keyed by the `external_user_key`.
|
||||
"""
|
||||
return OrderedDict((
|
||||
row.get(REQUEST_STUDENT_KEY),
|
||||
{
|
||||
'program_uuid': program_uuid,
|
||||
'curriculum_uuid': row.get('curriculum_uuid'),
|
||||
'status': row.get('status'),
|
||||
'external_user_key': row.get(REQUEST_STUDENT_KEY),
|
||||
})
|
||||
for row in request.data
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentModifyRequestSerializer,
|
||||
self.modify_program_enrollment,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def _remove_existing_entries(self, program_uuid, student_data):
|
||||
""" Helper method to remove entries that have existing ProgramEnrollment records. """
|
||||
result = {}
|
||||
existing_enrollments = ProgramEnrollment.bulk_read_by_student_key(program_uuid, student_data)
|
||||
for enrollment in existing_enrollments:
|
||||
result[enrollment.external_user_key] = CourseEnrollmentResponseStatuses.CONFLICT
|
||||
student_data.pop(enrollment.external_user_key)
|
||||
return result
|
||||
@verify_program_exists
|
||||
def put(self, request, **kwargs):
|
||||
"""
|
||||
Create/modify program enrollments for a list of learners
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
self.create_or_modify_program_enrollment,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def _get_created_or_updated_response(
|
||||
self, request, created_or_updated_data, response_data, default_status=status.HTTP_201_CREATED
|
||||
):
|
||||
def validate_enrollment_request(self, enrollment, seen_student_keys, serializer_class):
|
||||
"""
|
||||
Validates the given enrollment record and checks that it isn't a duplicate
|
||||
"""
|
||||
student_key = enrollment['student_key']
|
||||
if student_key in seen_student_keys:
|
||||
return CourseEnrollmentResponseStatuses.DUPLICATED
|
||||
seen_student_keys.add(student_key)
|
||||
enrollment_serializer = serializer_class(data=enrollment)
|
||||
try:
|
||||
enrollment_serializer.is_valid(raise_exception=True)
|
||||
except ValidationError as e:
|
||||
if enrollment_serializer.has_invalid_status():
|
||||
return CourseEnrollmentResponseStatuses.INVALID_STATUS
|
||||
else:
|
||||
raise e
|
||||
|
||||
def create_or_modify_enrollments(self, request, program_uuid, serializer_class, operation, success_status):
|
||||
"""
|
||||
Process a list of program course enrollment request objects
|
||||
and create or modify enrollments based on method
|
||||
"""
|
||||
results = {}
|
||||
seen_student_keys = set()
|
||||
enrollments = []
|
||||
|
||||
if not isinstance(request.data, list):
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
if len(request.data) > MAX_ENROLLMENT_RECORDS:
|
||||
return Response(
|
||||
'enrollment limit {}'.format(MAX_ENROLLMENT_RECORDS),
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
|
||||
)
|
||||
|
||||
try:
|
||||
for enrollment_request in request.data:
|
||||
error_status = self.validate_enrollment_request(enrollment_request, seen_student_keys, serializer_class)
|
||||
if error_status:
|
||||
results[enrollment_request["student_key"]] = error_status
|
||||
else:
|
||||
enrollments.append(enrollment_request)
|
||||
except KeyError: # student_key is not in enrollment_request
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
except TypeError: # enrollment_request isn't a dict
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
except ValidationError: # there was some other error raised by the serializer
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
|
||||
for enrollment in enrollments:
|
||||
student_key = enrollment["student_key"]
|
||||
if student_key in results and results[student_key] == ProgramEnrollmentResponseStatuses.DUPLICATED:
|
||||
continue
|
||||
try:
|
||||
program_enrollment = program_enrollments[student_key]
|
||||
except KeyError:
|
||||
program_enrollment = None
|
||||
results[student_key] = operation(enrollment, program_uuid, program_enrollment)
|
||||
|
||||
return self._get_created_or_updated_response(results, success_status)
|
||||
|
||||
def create_program_enrollment(self, request_data, program_uuid, program_enrollment):
|
||||
"""
|
||||
Create new ProgramEnrollment, unless the learner is already enrolled in the program
|
||||
"""
|
||||
if program_enrollment:
|
||||
return ProgramEnrollmentResponseStatuses.CONFLICT
|
||||
|
||||
student_key = request_data.get('student_key')
|
||||
try:
|
||||
user = get_user_by_program_id(student_key, program_uuid)
|
||||
except ProviderDoesNotExistException:
|
||||
# IDP has not yet been set up, just create waiting enrollments
|
||||
user = None
|
||||
|
||||
enrollment = ProgramEnrollment.objects.create(
|
||||
user=user,
|
||||
external_user_key=student_key,
|
||||
program_uuid=program_uuid,
|
||||
curriculum_uuid=request_data.get('curriculum_uuid'),
|
||||
status=request_data.get('status')
|
||||
)
|
||||
return enrollment.status
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
|
||||
"""
|
||||
Change the status of an existing program enrollment
|
||||
"""
|
||||
if not program_enrollment:
|
||||
return ProgramEnrollmentResponseStatuses.NOT_IN_PROGRAM
|
||||
|
||||
program_enrollment.status = request_data.get('status')
|
||||
program_enrollment.save()
|
||||
return program_enrollment.status
|
||||
|
||||
def create_or_modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
|
||||
if program_enrollment:
|
||||
return self.modify_program_enrollment(request_data, program_uuid, program_enrollment)
|
||||
else:
|
||||
return self.create_program_enrollment(request_data, program_uuid, program_enrollment)
|
||||
|
||||
def get_existing_program_enrollments(self, program_uuid, student_data):
|
||||
""" Returns the existing program enrollments for the given students and program """
|
||||
student_keys = [data['student_key'] for data in student_data]
|
||||
return {
|
||||
e.external_user_key: e
|
||||
for e in ProgramEnrollment.bulk_read_by_student_key(program_uuid, student_keys)
|
||||
}
|
||||
|
||||
def _get_created_or_updated_response(self, response_data, default_status=status.HTTP_201_CREATED):
|
||||
"""
|
||||
Helper method to determine an appropirate HTTP response status code.
|
||||
"""
|
||||
response_status = default_status
|
||||
|
||||
if not created_or_updated_data:
|
||||
good_count = len([
|
||||
v for v in response_data.values()
|
||||
if v not in CourseEnrollmentResponseStatuses.ERROR_STATUSES
|
||||
])
|
||||
if not good_count:
|
||||
response_status = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
elif len(request.data) != len(created_or_updated_data):
|
||||
elif good_count != len(response_data):
|
||||
response_status = status.HTTP_207_MULTI_STATUS
|
||||
|
||||
return Response(
|
||||
|
||||
@@ -67,16 +67,15 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
|
||||
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):
|
||||
def bulk_read_by_student_key(cls, program_uuid, student_keys):
|
||||
"""
|
||||
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.
|
||||
student_keys - list of student keys
|
||||
"""
|
||||
return cls.objects.filter(
|
||||
program_uuid=program_uuid,
|
||||
external_user_key__in=list(student_data.keys()),
|
||||
external_user_key__in=student_keys,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user