Moved paginators to edx_rest_framework_extensions package

Pagination should be standard and easy to adopt so moved the paginators
into the edx_rest_framework_extensions package so they can be easily
installed into other app/projects
This commit is contained in:
George Babey
2018-02-14 18:04:23 -05:00
parent 15e07d7cc5
commit 26bac9f611
9 changed files with 7 additions and 321 deletions

View File

@@ -20,7 +20,7 @@ from entitlements.utils import is_course_run_entitlement_fullfillable
from lms.djangoapps.commerce.utils import refund_entitlement
from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from openedx.core.lib.api.paginators import DefaultPagination
from edx_rest_framework_extensions.paginators import DefaultPagination
from student.models import CourseEnrollment
from student.models import CourseEnrollmentException, AlreadyEnrolledError

View File

@@ -1,6 +1,6 @@
""" CCX API v0 Paginators. """
from openedx.core.lib.api.paginators import DefaultPagination
from edx_rest_framework_extensions.paginators import DefaultPagination
class CCXAPIPagination(DefaultPagination):

View File

@@ -5,7 +5,7 @@ Course API Views
from django.core.exceptions import ValidationError
from rest_framework.generics import ListAPIView, RetrieveAPIView
from openedx.core.lib.api.paginators import NamespacedPageNumberPagination
from edx_rest_framework_extensions.paginators import NamespacedPageNumberPagination
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from .api import course_detail, list_courses

View File

@@ -3,7 +3,7 @@ Discussion API pagination support
"""
from rest_framework.utils.urls import replace_query_param
from openedx.core.lib.api.paginators import NamespacedPageNumberPagination
from edx_rest_framework_extensions.paginators import NamespacedPageNumberPagination
class _Page(object):

View File

@@ -24,7 +24,7 @@ from rest_framework_oauth.authentication import OAuth2Authentication
from courseware.courses import get_course_with_access, has_access
from django_comment_client.utils import has_discussion_privileges
from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership
from openedx.core.lib.api.paginators import DefaultPagination, paginate_search_results
from edx_rest_framework_extensions.paginators import DefaultPagination, paginate_search_results
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.permissions import IsStaffOrReadOnly
from openedx.core.lib.api.view_utils import (

View File

@@ -2373,7 +2373,7 @@ CSRF_COOKIE_SECURE = False
######################### Django Rest Framework ########################
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'openedx.core.lib.api.paginators.DefaultPagination',
'DEFAULT_PAGINATION_CLASS': 'edx_rest_framework_extensions.paginators.DefaultPagination',
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
),

View File

@@ -21,7 +21,7 @@ from rest_framework_oauth.authentication import OAuth2Authentication
import eventtracking
from openedx.core.djangoapps.bookmarks.api import BookmarksLimitReachedError
from openedx.core.lib.api.paginators import DefaultPagination
from edx_rest_framework_extensions.paginators import DefaultPagination
from openedx.core.lib.api.permissions import IsUserInUrl
from openedx.core.lib.url_utils import unquote_slashes
from xmodule.modulestore.exceptions import ItemNotFoundError

View File

@@ -1,124 +0,0 @@
""" Paginatator methods for edX API implementations."""
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404
from rest_framework import pagination
from rest_framework.response import Response
class DefaultPagination(pagination.PageNumberPagination):
"""
Default paginator for APIs in edx-platform.
This is configured in settings to be automatically used
by any subclass of Django Rest Framework's generic API views.
"""
page_size_query_param = "page_size"
max_page_size = 100
def get_paginated_response(self, data):
"""
Annotate the response with pagination information.
"""
return Response({
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'count': self.page.paginator.count,
'num_pages': self.page.paginator.num_pages,
'current_page': self.page.number,
'start': (self.page.number - 1) * self.get_page_size(self.request),
'results': data
})
class NamespacedPageNumberPagination(pagination.PageNumberPagination):
"""
Pagination scheme that returns results with pagination metadata
embedded in a "pagination" attribute. Can be used with data
that comes as a list of items, or as a dict with a "results"
attribute that contains a list of items.
"""
page_size_query_param = "page_size"
def get_result_count(self):
"""
Returns total number of results
"""
return self.page.paginator.count
def get_num_pages(self):
"""
Returns total number of pages the results are divided into
"""
return self.page.paginator.num_pages
def get_paginated_response(self, data):
"""
Annotate the response with pagination information
"""
metadata = {
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'count': self.get_result_count(),
'num_pages': self.get_num_pages(),
}
if isinstance(data, dict):
if 'results' not in data:
raise TypeError(u'Malformed result dict')
data['pagination'] = metadata
else:
data = {
'results': data,
'pagination': metadata,
}
return Response(data)
def paginate_search_results(object_class, search_results, page_size, page):
"""
Takes edx-search results and returns a Page object populated
with db objects for that page.
:param object_class: Model class to use when querying the db for objects.
:param search_results: edX-search results.
:param page_size: Number of results per page.
:param page: Page number.
:return: Paginator object with model objects
"""
paginator = Paginator(search_results['results'], page_size)
# This code is taken from within the GenericAPIView#paginate_queryset method.
# It is common code, but
try:
page_number = paginator.validate_number(page)
except InvalidPage:
if page == 'last':
page_number = paginator.num_pages
else:
raise Http404("Page is not 'last', nor can it be converted to an int.")
try:
paged_results = paginator.page(page_number)
except InvalidPage as exception:
raise Http404(
"Invalid page {page_number}: {message}".format(
page_number=page_number,
message=str(exception)
)
)
search_queryset_pks = [item['data']['pk'] for item in paged_results.object_list]
queryset = object_class.objects.filter(pk__in=search_queryset_pks)
def ordered_objects(primary_key):
""" Returns database object matching the search result object"""
for obj in queryset:
if obj.pk == primary_key:
return obj
# map over the search results and get a list of database objects in the same order
object_results = map(ordered_objects, search_queryset_pks)
paged_results.object_list = object_results
return paged_results

View File

@@ -1,190 +0,0 @@
""" Tests paginator methods """
from collections import namedtuple
import ddt
from mock import Mock, MagicMock
from nose.plugins.attrib import attr
from unittest import TestCase
from django.http import Http404
from django.test import RequestFactory
from rest_framework import serializers
from openedx.core.lib.api.paginators import NamespacedPageNumberPagination, paginate_search_results
@attr(shard=2)
@ddt.ddt
class PaginateSearchResultsTestCase(TestCase):
"""Test cases for paginate_search_results method"""
def setUp(self):
super(PaginateSearchResultsTestCase, self).setUp()
self.default_size = 6
self.default_page = 1
self.search_results = {
"count": 3,
"took": 1,
"results": [
{
'_id': 0,
'data': {
'pk': 0,
'name': 'object 0'
}
},
{
'_id': 1,
'data': {
'pk': 1,
'name': 'object 1'
}
},
{
'_id': 2,
'data': {
'pk': 2,
'name': 'object 2'
}
},
{
'_id': 3,
'data': {
'pk': 3,
'name': 'object 3'
}
},
{
'_id': 4,
'data': {
'pk': 4,
'name': 'object 4'
}
},
{
'_id': 5,
'data': {
'pk': 5,
'name': 'object 5'
}
},
]
}
self.mock_model = Mock()
self.mock_model.objects = Mock()
self.mock_model.objects.filter = Mock()
@ddt.data(
(1, 1, True),
(1, 3, True),
(1, 5, True),
(1, 10, False),
(2, 1, True),
(2, 3, False),
(2, 5, False),
)
@ddt.unpack
def test_paginated_results(self, page_number, page_size, has_next):
""" Test the page returned has the expected db objects and acts
like a proper page object.
"""
id_range = get_object_range(page_number, page_size)
db_objects = [build_mock_object(obj_id) for obj_id in id_range]
self.mock_model.objects.filter = MagicMock(return_value=db_objects)
page = paginate_search_results(self.mock_model, self.search_results, page_size, page_number)
self.mock_model.objects.filter.assert_called_with(pk__in=id_range)
self.assertEquals(db_objects, page.object_list)
self.assertTrue(page.number, page_number)
self.assertEquals(page.has_next(), has_next)
def test_paginated_results_last_keyword(self):
""" Test the page returned has the expected db objects and acts
like a proper page object using 'last' keyword.
"""
page_number = 2
page_size = 3
id_range = get_object_range(page_number, page_size)
db_objects = [build_mock_object(obj_id) for obj_id in id_range]
self.mock_model.objects.filter = MagicMock(return_value=db_objects)
page = paginate_search_results(self.mock_model, self.search_results, page_size, 'last')
self.mock_model.objects.filter.assert_called_with(pk__in=id_range)
self.assertEquals(db_objects, page.object_list)
self.assertTrue(page.number, page_number)
self.assertFalse(page.has_next())
@ddt.data(10, -1, 0, 'str')
def test_invalid_page_number(self, page_num):
""" Test that a Http404 error is raised with non-integer and out-of-range pages
"""
with self.assertRaises(Http404):
paginate_search_results(self.mock_model, self.search_results, self.default_size, page_num)
@attr(shard=2)
class NamespacedPaginationTestCase(TestCase):
"""
Test behavior of `NamespacedPageNumberPagination`
"""
TestUser = namedtuple('TestUser', ['username', 'email'])
class TestUserSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Simple serializer to paginate results from
"""
username = serializers.CharField()
email = serializers.CharField()
expected_data = {
'results': [
{'username': 'user_5', 'email': 'user_5@example.com'},
{'username': 'user_6', 'email': 'user_6@example.com'},
{'username': 'user_7', 'email': 'user_7@example.com'},
{'username': 'user_8', 'email': 'user_8@example.com'},
{'username': 'user_9', 'email': 'user_9@example.com'},
],
'pagination': {
'next': 'http://testserver/endpoint?page=3&page_size=5',
'previous': 'http://testserver/endpoint?page_size=5',
'count': 25,
'num_pages': 5,
}
}
def setUp(self):
super(NamespacedPaginationTestCase, self).setUp()
self.paginator = NamespacedPageNumberPagination()
self.users = [self.TestUser('user_{}'.format(idx), 'user_{}@example.com'.format(idx)) for idx in xrange(25)]
self.request_factory = RequestFactory()
def test_basic_pagination(self):
request = self.request_factory.get('/endpoint', data={'page': 2, 'page_size': 5})
request.query_params = {'page': 2, 'page_size': 5}
paged_users = self.paginator.paginate_queryset(self.users, request)
results = self.TestUserSerializer(paged_users, many=True).data
self.assertEqual(self.expected_data, self.paginator.get_paginated_response(results).data)
def build_mock_object(obj_id):
""" Build a mock object with the passed id"""
mock_object = Mock()
object_config = {
'pk': obj_id,
'name': "object {}".format(obj_id)
}
mock_object.configure_mock(**object_config)
return mock_object
def get_object_range(page, page_size):
""" Get the range of expected object ids given a page and page size.
This will take into account the max_id of the sample data. Currently 5.
"""
max_id = 5
start = min((page - 1) * page_size, max_id)
end = min(start + page_size, max_id + 1)
return range(start, end)