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:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user