diff --git a/lms/djangoapps/program_enrollments/api/v1/constants.py b/lms/djangoapps/program_enrollments/api/v1/constants.py index b2e731ab56..1597129bee 100644 --- a/lms/djangoapps/program_enrollments/api/v1/constants.py +++ b/lms/djangoapps/program_enrollments/api/v1/constants.py @@ -6,6 +6,9 @@ PROGRAM_UUID_PATTERN = r'(?P[A-Fa-f0-9-]+)' MAX_ENROLLMENT_RECORDS = 25 +# The name of the key that identifies students for POST/PATCH requests +REQUEST_STUDENT_KEY = 'student_key' + class CourseEnrollmentResponseStatuses(object): """ diff --git a/lms/djangoapps/program_enrollments/api/v1/serializers.py b/lms/djangoapps/program_enrollments/api/v1/serializers.py index 47512d69ec..856dba0966 100644 --- a/lms/djangoapps/program_enrollments/api/v1/serializers.py +++ b/lms/djangoapps/program_enrollments/api/v1/serializers.py @@ -19,8 +19,15 @@ class ProgramEnrollmentSerializer(serializers.ModelSerializer): validators = [] def validate(self, attrs): - enrollment = ProgramEnrollment(**attrs) - enrollment.full_clean() + """ This modifies self.instance in the case of updates """ + if not self.instance: + enrollment = ProgramEnrollment(**attrs) + enrollment.full_clean() + else: + for key, value in attrs.items(): + setattr(self.instance, key, value) + self.instance.full_clean() + return attrs def create(self, validated_data): diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py index 20d079f9cb..d954d6ae19 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -7,10 +7,10 @@ import json from uuid import uuid4 import ddt +import mock +from django.contrib.auth.models import User 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 @@ -18,18 +18,17 @@ from six import text_type from course_modes.models import CourseMode from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory +from lms.djangoapps.program_enrollments.api.v1.constants import MAX_ENROLLMENT_RECORDS, REQUEST_STUDENT_KEY from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses as CourseStatuses from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL -from openedx.core.djangoapps.catalog.tests.factories import ( - CourseFactory, - OrganizationFactory as CatalogOrganizationFactory, - ProgramFactory, -) +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory +from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory +from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangolib.testing.utils import CacheIsolationMixin -from student.tests.factories import UserFactory, CourseEnrollmentFactory +from student.tests.factories import CourseEnrollmentFactory, UserFactory class ListViewTestMixin(object): @@ -703,10 +702,14 @@ class ProgramEnrollmentViewPostTests(APITestCase): global_staff = GlobalStaffFactory.create(username='global-staff', password='password') self.client.login(username=global_staff.username, password='password') + def tearDown(self): + 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, - 'external_user_key': external_user_key or str(uuid4().hex[0:10]), 'curriculum_uuid': str(uuid4()) } @@ -719,7 +722,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): curriculum_uuids = [curriculum_uuid, curriculum_uuid, uuid4()] post_data = [ { - 'external_user_key': e, + REQUEST_STUDENT_KEY: e, 'status': s, 'curriculum_uuid': str(c) } @@ -735,10 +738,10 @@ class ProgramEnrollmentViewPostTests(APITestCase): ): response = self.client.post(url, json.dumps(post_data), content_type='application/json') - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) for i in range(3): - enrollment = ProgramEnrollment.objects.filter(external_user_key=external_user_keys[i])[0] + enrollment = ProgramEnrollment.objects.get(external_user_key=external_user_keys[i]) self.assertEqual(enrollment.external_user_key, external_user_keys[i]) self.assertEqual(enrollment.program_uuid, program_key) @@ -753,7 +756,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): post_data = [ { 'status': 'enrolled', - 'external_user_key': 'abc1', + REQUEST_STUDENT_KEY: 'abc1', 'curriculum_uuid': str(curriculum_uuid) } ] @@ -770,9 +773,9 @@ class ProgramEnrollmentViewPostTests(APITestCase): ): response = self.client.post(url, json.dumps(post_data), content_type='application/json') - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) - enrollment = ProgramEnrollment.objects.first() + enrollment = ProgramEnrollment.objects.get(external_user_key='abc1') self.assertEqual(enrollment.external_user_key, 'abc1') self.assertEqual(enrollment.program_uuid, program_key) @@ -783,7 +786,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): def test_enrollment_payload_limit(self): post_data = [] - for _ in range(26): + for _ in range(MAX_ENROLLMENT_RECORDS + 1): post_data += self.student_enrollment('enrolled') url = reverse('programs_api:v1:program_enrollments', args=[uuid4()]) @@ -794,7 +797,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): return_value=None ): response = self.client.post(url, json.dumps(post_data), content_type='application/json') - self.assertEqual(response.status_code, 413) + self.assertEqual(response.status_code, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) def test_duplicate_enrollment(self): post_data = [ @@ -812,7 +815,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): ): response = self.client.post(url, json.dumps(post_data), content_type='application/json') - self.assertEqual(response.status_code, 207) + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) self.assertEqual(response.data, { '001': 'duplicated', '002': 'enrolled', @@ -833,7 +836,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): content_type='application/json' ) - self.assertEqual(response.status_code, 422) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) def test_unauthenticated(self): self.client.logout() @@ -847,7 +850,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): content_type='application/json' ) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_program_unauthorized(self): student = UserFactory.create(username='student', password='password') @@ -862,7 +865,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): json.dumps(post_data), content_type='application/json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_program_not_found(self): post_data = [ @@ -874,7 +877,7 @@ class ProgramEnrollmentViewPostTests(APITestCase): json.dumps(post_data), content_type='application/json' ) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_partially_valid_enrollment(self): @@ -892,8 +895,224 @@ class ProgramEnrollmentViewPostTests(APITestCase): ): response = self.client.post(url, json.dumps(post_data), content_type='application/json') - self.assertEqual(response.status_code, 207) + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) self.assertEqual(response.data, { '001': 'invalid-status', '003': 'pending', }) + + +class ProgramEnrollmentViewPatchTests(APITestCase): + """ + Tests for the ProgramEnrollment view PATCH method. + """ + def setUp(self): + super(ProgramEnrollmentViewPatchTests, self).setUp() + + self.program_uuid = '00000000-1111-2222-3333-444444444444' + self.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444' + self.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444' + + self.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course') + _ = CourseOverviewFactory.create(id=self.course_id) + + self.password = 'password' + self.student = UserFactory.create(username='student', password=self.password) + self.global_staff = GlobalStaffFactory.create(username='global-staff', password=self.password) + + 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 test_successfully_patched_program_enrollment(self): + enrollments = {} + for i in xrange(4): + user_key = 'user-{}'.format(i) + instance = ProgramEnrollment.objects.create( + program_uuid=self.program_uuid, + curriculum_uuid=self.curriculum_uuid, + user=None, + status='pending', + external_user_key=user_key, + ) + enrollments[user_key] = instance + + post_data = [ + {REQUEST_STUDENT_KEY: 'user-1', 'status': 'withdrawn'}, + {REQUEST_STUDENT_KEY: 'user-2', 'status': 'suspended'}, + {REQUEST_STUDENT_KEY: 'user-3', 'status': 'enrolled'}, + ] + + url = reverse('programs_api:v1:program_enrollments', args=[self.program_uuid]) + with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True): + response = self.client.patch(url, json.dumps(post_data), content_type='application/json') + + for enrollment in enrollments.values(): + enrollment.refresh_from_db() + + expected_statuses = { + 'user-0': 'pending', + 'user-1': 'withdrawn', + 'user-2': 'suspended', + 'user-3': 'enrolled', + } + for user_key, enrollment in enrollments.items(): + assert expected_statuses[user_key] == enrollment.status + + expected_response = { + 'user-1': 'withdrawn', + 'user-2': 'suspended', + 'user-3': 'enrolled', + } + 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, status.HTTP_422_UNPROCESSABLE_ENTITY) + + def test_duplicate_enrollment(self): + enrollments = {} + for i in xrange(4): + user_key = 'user-{}'.format(i) + instance = ProgramEnrollment.objects.create( + program_uuid=self.program_uuid, + curriculum_uuid=self.curriculum_uuid, + user=None, + status='pending', + external_user_key=user_key, + ) + enrollments[user_key] = instance + + patch_data = [ + self.student_enrollment('enrolled', 'user-1'), + self.student_enrollment('enrolled', 'user-2'), + self.student_enrollment('enrolled', 'user-1'), + ] + + url = reverse('programs_api:v1:program_enrollments', args=[self.program_uuid]) + 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') + + for enrollment in enrollments.values(): + enrollment.refresh_from_db() + + expected_statuses = { + 'user-0': 'pending', + 'user-1': 'pending', + 'user-2': 'enrolled', + 'user-3': 'pending', + } + for user_key, enrollment in enrollments.items(): + assert expected_statuses[user_key] == enrollment.status + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(response.data, { + 'user-1': 'duplicated', + 'user-2': 'enrolled', + }) + + def test_partially_valid_enrollment(self): + enrollments = {} + for i in xrange(4): + user_key = 'user-{}'.format(i) + instance = ProgramEnrollment.objects.create( + program_uuid=self.program_uuid, + curriculum_uuid=self.curriculum_uuid, + user=None, + status='pending', + external_user_key=user_key, + ) + enrollments[user_key] = instance + + patch_data = [ + self.student_enrollment('new', 'user-1'), + self.student_enrollment('withdrawn', 'user-3'), + self.student_enrollment('enrolled', 'user-who-is-not-in-program'), + ] + + url = reverse('programs_api:v1:program_enrollments', args=[self.program_uuid]) + 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') + + for enrollment in enrollments.values(): + enrollment.refresh_from_db() + + expected_statuses = { + 'user-0': 'pending', + 'user-1': 'pending', + 'user-2': 'pending', + 'user-3': 'withdrawn', + } + for user_key, enrollment in enrollments.items(): + assert expected_statuses[user_key] == enrollment.status + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(response.data, { + 'user-1': 'invalid-status', + 'user-3': 'withdrawn', + 'user-who-is-not-in-program': 'not-in-program', + }) diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index 970205450e..ceadcc8d8d 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -17,7 +17,11 @@ from rest_framework.exceptions import ValidationError from rest_framework.pagination import CursorPagination from rest_framework.response import Response -from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses, MAX_ENROLLMENT_RECORDS +from lms.djangoapps.program_enrollments.api.v1.constants import ( + CourseEnrollmentResponseStatuses, + MAX_ENROLLMENT_RECORDS, + REQUEST_STUDENT_KEY, +) from lms.djangoapps.program_enrollments.api.v1.serializers import ( ProgramCourseEnrollmentListSerializer, ProgramCourseEnrollmentRequestSerializer, @@ -133,7 +137,7 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): * 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, + student_key: string representation of a learner in partner systems, 'curriculum_uuid': string representation of a curriculum } Example: @@ -191,6 +195,70 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): * 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. + + Update + ========== + 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 with their updated enrollment status: + { + 'status': A choice of the following statuses: ['enrolled', 'pending', 'withdrawn', 'suspended'], + student_key: string representation of a learner in partner systems + } + Example: + [ + { + "status": "enrolled", + "external_user_key": "123", + },{ + "status": "withdrawn", + "external_user_key": "456", + },{ + "status": "pending", + "external_user_key": "789", + },{ + "status": "suspended", + "external_user_key": "abc", + }, + ] + + Returns: + * Response Body: {: } 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 + * 'not-in-program' - the user is not in the program and cannot be updated + * 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': 'not-in-program', + '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 updated. """ authentication_classes = ( JwtAuthentication, @@ -213,38 +281,20 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): @verify_program_exists def post(self, request, *args, **kwargs): """ - This is the POST for ProgramEnrollments + Create program enrollments for a list of learners """ - if len(request.data) > 25: + 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 = 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]) + student_data = self._request_data_by_student_key(request, program_uuid) 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) + response_data.update(self._remove_duplicate_entries(request, student_data)) + response_data.update(self._remove_existing_entries(program_uuid, student_data)) enrollments_to_create = {} @@ -268,27 +318,106 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): status.HTTP_422_UNPROCESSABLE_ENTITY ) - for enrollment_serializer in enrollments_to_create.values(): - # create the model + # 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() - # TODO: make this a bulk save - if not enrollments_to_create: + return self._get_created_or_updated_response(request, enrollments_to_create, response_data) + + @verify_program_exists + def patch(self, request, **kwargs): + """ + Modify the program enrollments for a list of learners + """ + if len(request.data) > MAX_ENROLLMENT_RECORDS: return Response( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, - data=response_data, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, 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', - ) + program_uuid = kwargs['program_uuid'] + student_data = self._request_data_by_student_key(request, program_uuid) + + 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 + ) + + 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 + + def _get_created_or_updated_response( + self, request, created_or_updated_data, 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: + response_status = status.HTTP_422_UNPROCESSABLE_ENTITY + elif len(request.data) != len(created_or_updated_data): + response_status = status.HTTP_207_MULTI_STATUS return Response( - status=status.HTTP_201_CREATED, + status=response_status, data=response_data, content_type='application/json', )