diff --git a/lms/djangoapps/grades/api/v1/gradebook_views.py b/lms/djangoapps/grades/api/v1/gradebook_views.py index e289a5d29f..c607da2cf0 100644 --- a/lms/djangoapps/grades/api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/api/v1/gradebook_views.py @@ -20,7 +20,6 @@ from lms.djangoapps.grades.api.v1.utils import ( USER_MODEL, CourseEnrollmentPagination, GradeViewMixin, - PaginatedAPIView, get_course_key, verify_course_exists ) @@ -43,7 +42,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.course_groups import cohorts from openedx.core.djangoapps.util.forms import to_bool -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView, view_auth_classes from openedx.core.lib.cache_utils import request_cached from student.auth import has_course_author_access from student.models import CourseEnrollment diff --git a/lms/djangoapps/grades/api/v1/utils.py b/lms/djangoapps/grades/api/v1/utils.py index a2af951fcb..8c31583841 100644 --- a/lms/djangoapps/grades/api/v1/utils.py +++ b/lms/djangoapps/grades/api/v1/utils.py @@ -82,39 +82,6 @@ class CourseEnrollmentPagination(CursorPagination): return self.page_size -class PaginatedAPIView(APIView): - """ - An `APIView` class enhanced with the pagination methods of `GenericAPIView`. - """ - # pylint: disable=attribute-defined-outside-init - @property - def paginator(self): - """ - The paginator instance associated with the view, or `None`. - """ - if not hasattr(self, '_paginator'): - if self.pagination_class is None: - self._paginator = None - else: - self._paginator = self.pagination_class() - return self._paginator - - def paginate_queryset(self, queryset): - """ - Return a single page of results, or `None` if pagination is disabled. - """ - if self.paginator is None: - return None - return self.paginator.paginate_queryset(queryset, self.request, view=self) - - def get_paginated_response(self, data): - """ - Return a paginated style `Response` object for the given output data. - """ - assert self.paginator is not None - return self.paginator.get_paginated_response(data) - - class GradeViewMixin(DeveloperErrorViewMixin): """ Mixin class for Grades related views. diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py index 2d9c82375b..cd1b11faa8 100644 --- a/lms/djangoapps/grades/api/v1/views.py +++ b/lms/djangoapps/grades/api/v1/views.py @@ -14,7 +14,6 @@ from lms.djangoapps.grades.api.serializers import GradingPolicySerializer from lms.djangoapps.grades.api.v1.utils import ( CourseEnrollmentPagination, GradeViewMixin, - PaginatedAPIView, get_course_key, verify_course_exists ) @@ -22,6 +21,7 @@ from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.models import PersistentCourseGrade from opaque_keys import InvalidKeyError from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser +from openedx.core.lib.api.view_utils import PaginatedAPIView from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) diff --git a/lms/djangoapps/program_enrollments/api/__init__.py b/lms/djangoapps/program_enrollments/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/program_enrollments/api/urls.py b/lms/djangoapps/program_enrollments/api/urls.py new file mode 100644 index 0000000000..5c7c7075a9 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/urls.py @@ -0,0 +1,12 @@ +""" +Grades API URLs. +""" + +from django.conf.urls import include, url + + +app_name = 'lms.djangoapps.program_enrollments' + +urlpatterns = [ + url(r'^v1/', include('program_enrollments.api.v1.urls', namespace='v1')) +] diff --git a/lms/djangoapps/program_enrollments/api/v1/__init__.py b/lms/djangoapps/program_enrollments/api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/program_enrollments/api/v1/serializers.py b/lms/djangoapps/program_enrollments/api/v1/serializers.py new file mode 100644 index 0000000000..75f2bbbe76 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/v1/serializers.py @@ -0,0 +1,42 @@ +""" +API Serializers +""" +from rest_framework import serializers + +from lms.djangoapps.program_enrollments.models import ProgramEnrollment + + +# pylint: disable=abstract-method +class ProgramEnrollmentSerializer(serializers.ModelSerializer): + """ + Serializer for Program Enrollments + """ + + class Meta(object): + model = ProgramEnrollment + fields = ('user', 'external_user_key', 'program_uuid', 'curriculum_uuid', 'status') + validators = [] + + def validate(self, attrs): + enrollment = ProgramEnrollment(**attrs) + enrollment.full_clean() + return attrs + + def create(self, validated_data): + return ProgramEnrollment.objects.create(**validated_data) + + +class ProgramEnrollmentListSerializer(serializers.Serializer): + """ + Serializer for listing enrollments in a program. + """ + student_key = serializers.CharField(source='external_user_key') + status = serializers.CharField() + account_exists = serializers.SerializerMethodField() + curriculum_uuid = serializers.UUIDField() + + class Meta(object): + model = ProgramEnrollment + + def get_account_exists(self, obj): + return bool(obj.user) diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/__init__.py b/lms/djangoapps/program_enrollments/api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/factories.py b/lms/djangoapps/program_enrollments/api/v1/tests/factories.py new file mode 100644 index 0000000000..df507355a5 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/v1/tests/factories.py @@ -0,0 +1,22 @@ +""" +Factories for Program Enrollment tests. +""" +from uuid import uuid4 + +import factory +from factory.django import DjangoModelFactory + +from lms.djangoapps.program_enrollments import models +from student.tests.factories import UserFactory + + +class ProgramEnrollmentFactory(DjangoModelFactory): + """ A Factory for the ProgramEnrollment model. """ + class Meta(object): + model = models.ProgramEnrollment + + user = factory.SubFactory(UserFactory) + external_user_key = None + program_uuid = uuid4() + curriculum_uuid = uuid4() + status = 'enrolled' diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py b/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py new file mode 100644 index 0000000000..d37d7b75f2 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_serializers.py @@ -0,0 +1,40 @@ +""" +Unit tests for ProgramEnrollment serializers. +""" +from __future__ import unicode_literals + +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 student.tests.factories import UserFactory + + +class ProgramEnrollmentSerializerTests(TestCase): + """ + Tests for the ProgramEnrollment serializer. + """ + def setUp(self): + """ + Set up the test data used in the specific tests + """ + super(ProgramEnrollmentSerializerTests, self).setUp() + self.user = UserFactory.create() + self.enrollment = ProgramEnrollment.objects.create( + user=self.user, + external_user_key='abc', + program_uuid=uuid4(), + curriculum_uuid=uuid4(), + status='enrolled' + ) + self.serializer = ProgramEnrollmentSerializer(instance=self.enrollment) + + def test_serializer_contains_expected_fields(self): + data = self.serializer.data + + self.assertEqual( + set(data.keys()), + {'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 new file mode 100644 index 0000000000..e38e085058 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -0,0 +1,173 @@ +""" +Unit tests for ProgramEnrollment views. +""" +from __future__ import unicode_literals + +import mock + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from six import text_type + +from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory +from lms.djangoapps.program_enrollments.models import ProgramEnrollment +from student.tests.factories import UserFactory + +from .factories import ProgramEnrollmentFactory + + +class ProgramEnrollmentListTest(APITestCase): + """ + Tests for GET calls to the Program Enrollments API. + """ + @classmethod + def setUpClass(cls): + super(ProgramEnrollmentListTest, cls).setUpClass() + cls.program_uuid = '00000000-1111-2222-3333-444444444444' + cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444' + cls.password = 'password' + cls.student = UserFactory.create(username='student', password=cls.password) + cls.global_staff = GlobalStaffFactory.create(username='global-staff', password=cls.password) + + @classmethod + def tearDownClass(cls): + super(ProgramEnrollmentListTest, cls).tearDownClass() + + def create_enrollments(self): + """ + Helper method for creating program enrollment records. + """ + for i in xrange(2): + user_key = 'user-{}'.format(i) + ProgramEnrollmentFactory.create( + program_uuid=self.program_uuid, + curriculum_uuid=self.curriculum_uuid, + user=None, + status='pending', + external_user_key=user_key, + ) + + for i in xrange(2, 4): + user_key = 'user-{}'.format(i) + ProgramEnrollmentFactory.create( + program_uuid=self.program_uuid, curriculum_uuid=self.curriculum_uuid, external_user_key=user_key, + ) + + self.addCleanup(self.destroy_enrollments) + + def destroy_enrollments(self): + """ + Deletes program enrollments associated with this test case's program_uuid. + """ + ProgramEnrollment.objects.filter(program_uuid=self.program_uuid).delete() + + def get_url(self, program_key=None): + return reverse('programs_api:v1:program_enrollments', kwargs={'program_key': program_key}) + + @mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None) + def test_404_if_no_program_with_key(self, mock_get_programs): + self.client.login(username=self.global_staff.username, password=self.password) + response = self.client.get(self.get_url(self.program_uuid)) + assert status.HTTP_404_NOT_FOUND == response.status_code + mock_get_programs.assert_called_once_with(uuid=self.program_uuid) + + def test_403_if_not_staff(self): + self.client.login(username=self.student.username, password=self.password) + response = self.client.get(self.get_url(self.program_uuid)) + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_401_if_anonymous(self): + response = self.client.get(self.get_url(self.program_uuid)) + assert status.HTTP_401_UNAUTHORIZED == response.status_code + + def test_200_empty_results(self): + self.client.login(username=self.global_staff.username, password=self.password) + + with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True): + response = self.client.get(self.get_url(self.program_uuid)) + + assert status.HTTP_200_OK == response.status_code + expected = { + 'next': None, + 'previous': None, + 'results': [], + } + assert expected == response.data + + def test_200_many_results(self): + self.client.login(username=self.global_staff.username, password=self.password) + + self.create_enrollments() + with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True): + response = self.client.get(self.get_url(self.program_uuid)) + + assert status.HTTP_200_OK == response.status_code + expected = { + 'next': None, + 'previous': None, + 'results': [ + { + 'student_key': 'user-0', 'status': 'pending', 'account_exists': False, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + { + 'student_key': 'user-1', 'status': 'pending', 'account_exists': False, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + { + 'student_key': 'user-2', 'status': 'enrolled', 'account_exists': True, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + { + 'student_key': 'user-3', 'status': 'enrolled', 'account_exists': True, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + ], + } + assert expected == response.data + + def test_200_many_pages(self): + self.client.login(username=self.global_staff.username, password=self.password) + + self.create_enrollments() + with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True): + url = self.get_url(self.program_uuid) + '?page_size=2' + response = self.client.get(url) + + assert status.HTTP_200_OK == response.status_code + expected_results = [ + { + 'student_key': 'user-0', 'status': 'pending', 'account_exists': False, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + { + 'student_key': 'user-1', 'status': 'pending', 'account_exists': False, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + ] + assert expected_results == response.data['results'] + # there's going to be a 'cursor' query param, but we have no way of knowing it's value + assert response.data['next'] is not None + assert self.get_url(self.program_uuid) in response.data['next'] + assert '?cursor=' in response.data['next'] + assert response.data['previous'] is None + + next_response = self.client.get(response.data['next']) + assert status.HTTP_200_OK == next_response.status_code + next_expected_results = [ + { + 'student_key': 'user-2', 'status': 'enrolled', 'account_exists': True, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + { + 'student_key': 'user-3', 'status': 'enrolled', 'account_exists': True, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + ] + assert next_expected_results == next_response.data['results'] + assert next_response.data['next'] is None + # there's going to be a 'cursor' query param, but we have no way of knowing it's value + assert next_response.data['previous'] is not None + assert self.get_url(self.program_uuid) in next_response.data['previous'] + assert '?cursor=' in next_response.data['previous'] diff --git a/lms/djangoapps/program_enrollments/api/v1/urls.py b/lms/djangoapps/program_enrollments/api/v1/urls.py new file mode 100644 index 0000000000..b055a0bc85 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/v1/urls.py @@ -0,0 +1,15 @@ +""" Program Enrollments API v1 URLs. """ +from django.conf.urls import url + +from lms.djangoapps.program_enrollments.api.v1.views import ProgramEnrollmentsView + + +app_name = 'lms.djangoapps.program_enrollments' + +urlpatterns = [ + url( + r'^programs/{program_key}/enrollments/$'.format(program_key=r'(?P[0-9a-fA-F-]+)'), + ProgramEnrollmentsView.as_view(), + name='program_enrollments' + ), +] diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py new file mode 100644 index 0000000000..89d40c0c7d --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +""" +ProgramEnrollment Views +""" +from __future__ import unicode_literals + +from functools import wraps + +from edx_rest_framework_extensions import permissions +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from rest_framework import status +from rest_framework.pagination import CursorPagination + +from lms.djangoapps.program_enrollments.api.v1.serializers import ProgramEnrollmentListSerializer +from lms.djangoapps.program_enrollments.models import ProgramEnrollment +from openedx.core.djangoapps.catalog.utils import get_programs +from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView + + +def verify_program_exists(view_func): + """ + Raises: + An API error if the `program_key` kwarg in the wrapped function + does not exist in the catalog programs cache. + """ + @wraps(view_func) + def wrapped_function(self, request, **kwargs): + """ + Wraps the given view_function. + """ + program_uuid = kwargs['program_key'] + program = get_programs(uuid=program_uuid) + if not program: + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='no program exists with given key', + error_code='program_does_not_exist' + ) + return view_func(self, request, **kwargs) + return wrapped_function + + +class ProgramEnrollmentPagination(CursorPagination): + """ + Pagination class for Program Enrollments. + """ + ordering = 'id' + page_size = 100 + page_size_query_param = 'page_size' + + def get_page_size(self, request): + """ + Get the page size based on the defined page size parameter if defined. + """ + try: + page_size_string = request.query_params[self.page_size_query_param] + return int(page_size_string) + except (KeyError, ValueError): + pass + + return self.page_size + + +class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): + """ + A view for Create/Read/Update methods on Program Enrollment data. + + Path: `/api/program_enrollments/v1/programs/{program_key}/enrollments/` + The path can contain an optional `page_size?=N` query parameter. The default page size is 100. + + Returns: + * 200: OK - Contains a paginated set of program enrollment data. + * 401: The requesting user is not authenticated. + * 403: The requesting user lacks access for the given program. + * 404: The requested program does not exist. + + Response: + In the case of a 200 response code, the response will include a paginated + data set. The `results` section of the response consists of a list of + program enrollment records, where each record contains the following keys: + * student_key: The identifier of the student enrolled in the program. + * status: The student's enrollment status. + * account_exists: A boolean indicating if the student has created an edx-platform user account. + * curriculum_uuid: The curriculum UUID of the enrollment record for the (student, program). + + Example: + { + "next": null, + "previous": "http://testserver.com/api/program_enrollments/v1/programs/{program_key}/enrollments/?curor=abcd", + "results": [ + { + "student_key": "user-0", "status": "pending", + "account_exists": False, "curriculum_uuid": "00000000-1111-2222-3333-444444444444" + }, + { + "student_key": "user-1", "status": "pending", + "account_exists": False, "curriculum_uuid": "00000001-1111-2222-3333-444444444444" + }, + { + "student_key": "user-2", "status": "enrolled", + "account_exists": True, "curriculum_uuid": "00000002-1111-2222-3333-444444444444" + }, + { + "student_key": "user-3", "status": "enrolled", + "account_exists": True, "curriculum_uuid": "00000003-1111-2222-3333-444444444444" + }, + ], + } + + """ + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,) + pagination_class = ProgramEnrollmentPagination + + @verify_program_exists + def get(self, request, program_key=None): + enrollments = ProgramEnrollment.objects.filter(program_uuid=program_key) + paginated_enrollments = self.paginate_queryset(enrollments) + serializer = ProgramEnrollmentListSerializer(paginated_enrollments, many=True) + return self.get_paginated_response(serializer.data) diff --git a/lms/djangoapps/program_enrollments/apps.py b/lms/djangoapps/program_enrollments/apps.py index aa2de26923..7cdabf684a 100644 --- a/lms/djangoapps/program_enrollments/apps.py +++ b/lms/djangoapps/program_enrollments/apps.py @@ -6,6 +6,8 @@ from __future__ import unicode_literals from django.apps import AppConfig +from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs + class ProgramEnrollmentsConfig(AppConfig): """ @@ -14,5 +16,11 @@ class ProgramEnrollmentsConfig(AppConfig): name = 'lms.djangoapps.program_enrollments' plugin_app = { - 'url_config': {}, + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: 'programs_api', + PluginURLs.REGEX: 'api/program_enrollments/', + PluginURLs.RELATIVE_PATH: 'api.urls', + } + }, } diff --git a/lms/djangoapps/program_enrollments/models.py b/lms/djangoapps/program_enrollments/models.py index 47ad804a58..3a3d9802d2 100644 --- a/lms/djangoapps/program_enrollments/models.py +++ b/lms/djangoapps/program_enrollments/models.py @@ -5,7 +5,9 @@ Django model specifications for the Program Enrollments API from __future__ import unicode_literals from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.db import models +from django.utils.translation import ugettext_lazy as _ from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField from simple_history.models import HistoricalRecords @@ -29,6 +31,7 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic class Meta(object): app_label = "program_enrollments" + unique_together = ('external_user_key', 'program_uuid', 'curriculum_uuid') # A student enrolled in a given (program, curriculum) should always # have a non-null ``user`` or ``external_user_key`` field (or both). @@ -51,6 +54,10 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic status = models.CharField(max_length=9, choices=STATUSES) historical_records = HistoricalRecords() + def clean(self): + if not (self.user or self.external_user_key): + raise ValidationError(_('One of user or external_user_key must not be null.')) + @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 2d36aaa153..362611fb58 100644 --- a/lms/djangoapps/program_enrollments/tests/test_models.py +++ b/lms/djangoapps/program_enrollments/tests/test_models.py @@ -21,10 +21,12 @@ class ProgramEnrollmentModelTests(TestCase): """ super(ProgramEnrollmentModelTests, self).setUp() self.user = UserFactory.create() + self.program_uuid = uuid4() + self.other_program_uuid = uuid4() self.enrollment = ProgramEnrollment.objects.create( user=self.user, external_user_key='abc', - program_uuid=uuid4(), + program_uuid=self.program_uuid, curriculum_uuid=uuid4(), status='enrolled' ) diff --git a/lms/djangoapps/program_enrollments/views.py b/lms/djangoapps/program_enrollments/views.py deleted file mode 100644 index dbcb7fb359..0000000000 --- a/lms/djangoapps/program_enrollments/views.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -""" -ProgramEnrollment Views -""" -from __future__ import unicode_literals - -# from django.shortcuts import render - -# Create your views here. diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 7568b45bd4..3ead968677 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -15,6 +15,7 @@ from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin from rest_framework.permissions import IsAuthenticated from rest_framework.request import clone_request from rest_framework.response import Response +from rest_framework.views import APIView from six import text_type, iteritems from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser @@ -304,3 +305,36 @@ class LazySequence(Sequence): self.iterable, self.est_len, ) + + +class PaginatedAPIView(APIView): + """ + An `APIView` class enhanced with the pagination methods of `GenericAPIView`. + """ + # pylint: disable=attribute-defined-outside-init + @property + def paginator(self): + """ + The paginator instance associated with the view, or `None`. + """ + if not hasattr(self, '_paginator'): + if self.pagination_class is None: + self._paginator = None + else: + self._paginator = self.pagination_class() + return self._paginator + + def paginate_queryset(self, queryset): + """ + Return a single page of results, or `None` if pagination is disabled. + """ + if self.paginator is None: + return None + return self.paginator.paginate_queryset(queryset, self.request, view=self) + + def get_paginated_response(self, data): + """ + Return a paginated style `Response` object for the given output data. + """ + assert self.paginator is not None + return self.paginator.get_paginated_response(data)