diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py b/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py index d37d7b75f2..3c11dac304 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py @@ -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' + ]) ) 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 d55e60b8e1..1492fceeb3 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -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', + }) diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index e4ff306e65..356382277f 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -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: {: } 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): """ diff --git a/lms/djangoapps/program_enrollments/models.py b/lms/djangoapps/program_enrollments/models.py index d6d134bbd7..ce0b2b6ebd 100644 --- a/lms/djangoapps/program_enrollments/models.py +++ b/lms/djangoapps/program_enrollments/models.py @@ -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): """ diff --git a/lms/djangoapps/program_enrollments/tests/test_models.py b/lms/djangoapps/program_enrollments/tests/test_models.py index 362611fb58..8d387b5fb0 100644 --- a/lms/djangoapps/program_enrollments/tests/test_models.py +++ b/lms/djangoapps/program_enrollments/tests/test_models.py @@ -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.