Commit 867c39b6 by Renzo Lucioni

Add ProxiedPagination class

ProxiedPagination proxies to either DRF's PageNumberPagination or LimitOffsetPagination. If no query parameters are passed, proxies to LimitOffsetPagination by default to preserve pre-existing behavior.

ECOM-6834
parent cccffba1
from rest_framework import pagination
from rest_framework.pagination import (
PageNumberPagination as BasePageNumberPagination,
LimitOffsetPagination,
)
class PageNumberPagination(pagination.PageNumberPagination):
class PageNumberPagination(BasePageNumberPagination):
page_size_query_param = 'page_size'
class ProxiedCall:
"""
Utility class used in conjunction with ProxiedPagination to route method
calls between pagination classes.
"""
def __init__(self, proxy, method_name):
self.proxy = proxy
self.method_name = method_name
def __call__(self, *args, **kwargs):
try:
# Currently, the only methods on DRF pagination classes which accept
# requests as positional arguments expect them as the second positional
# argument. Hence, we expect the same to be true here. If DRF's pagination
# classes are changed such that this is no longer true, code below will
# break, loudly, so that the maintainer realizes there is a problem.
request = args[1]
except IndexError:
request = None
paginator = self._get_paginator(request=request if request else False)
# Look up the method and call it.
return getattr(paginator, self.method_name)(*args, **kwargs)
def _get_paginator(self, request=False):
for paginator, query_param in self.proxy.paginators:
# DRF's ListModelMixin calls paginate_queryset() prior to get_paginated_response(),
# storing the original request on the paginator's `request` attribute. If the paginator
# has this attribute, it means we've routed a previous paginate_queryset() call
# to it and should continue using it.
is_request_stored = hasattr(paginator, 'request')
# If a request is available, look for the presence of a query parameter
# indicating that we should use this paginator.
is_query_param_present = request and request.query_params.get(query_param) # pylint: disable=no-member
if is_request_stored or is_query_param_present:
return paginator
# If we don't have a stored request or query parameter to go off of,
# default to the last paginator in the list on the proxy. To preserve
# pre-existing behavior, this is currently LimitOffsetPagination.
return paginator # pylint: disable=undefined-loop-variable
class ProxiedPagination:
"""
Pagination class which proxies to either DRF's PageNumberPagination or
LimitOffsetPagination.
The following are all valid:
http://api.example.org/accounts/?page=4
http://api.example.org/accounts/?page=4&page_size=100
http://api.example.org/accounts/?limit=100
http://api.example.org/accounts/?offset=400&limit=100
If no query parameters are passed, proxies to LimitOffsetPagination by default.
"""
def __init__(self):
page_number_paginator = PageNumberPagination()
limit_offset_paginator = LimitOffsetPagination()
self.paginators = [
(page_number_paginator, page_number_paginator.page_query_param),
(limit_offset_paginator, limit_offset_paginator.limit_query_param),
]
def __getattr__(self, name):
# For each paginator, check if the requested attribute is defined.
# If the attr is defined on both paginators, we take the one defined for
# LimitOffsetPagination. As of this writing, `display_page_controls` is
# the only attr shared by the two pagination classes.
for paginator, __ in self.paginators:
try:
attr = getattr(paginator, name)
except AttributeError:
pass
# The value defined for the attribute in the paginators may be None, which
# prevents us from defaulting `attr` to None.
try:
attr
except NameError:
# The attribute wasn't found on either paginator.
raise AttributeError
else:
# The attribute was found. If it's callable, return a ProxiedCall
# which will route method calls to the correct paginator.
if callable(attr):
return ProxiedCall(self, name)
else:
return attr
from django.test import TestCase
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.pagination import PageNumberPagination, ProxiedPagination
class ProxiedPaginationTests(TestCase):
def setUp(self):
super().setUp()
self.proxied_paginator = ProxiedPagination()
self.page_number_paginator = PageNumberPagination()
self.limit_offset_paginator = LimitOffsetPagination()
self.queryset = range(100)
def get_request(self, **data):
"""
Constructs an instance of DRF's internal representation of a Request,
required for testing in this context.
"""
factory = APIRequestFactory()
return Request(factory.get('/', data))
def paginate_queryset(self, paginator, request):
return list(paginator.paginate_queryset(self.queryset, request))
def get_paginated_content(self, paginator, queryset):
response = paginator.get_paginated_response(queryset)
return response.data
def assert_proxied(self, expected_paginator, request):
proxied_queryset = self.paginate_queryset(self.proxied_paginator, request)
expected_queryset = self.paginate_queryset(expected_paginator, request)
self.assertEqual(proxied_queryset, expected_queryset)
proxied_data = self.get_paginated_content(self.proxied_paginator, proxied_queryset)
expected_data = self.get_paginated_content(expected_paginator, expected_queryset)
self.assertEqual(proxied_data, expected_data)
def test_default_pagination(self):
"""
Verify that ProxiedPagination behaves like LimitOffsetPagination by
default, when no query parameters are present.
"""
request = self.get_request()
self.assert_proxied(self.limit_offset_paginator, request)
def test_page_number_pagination(self):
"""
Verify that ProxiedPagination proxies to PageNumberPagination when a
`page` query parameter is present.
"""
request = self.get_request(page=2)
self.assert_proxied(self.page_number_paginator, request)
def test_limit_offset_pagination(self):
"""
Verify that ProxiedPagination proxies to LimitOffsetPagination when a
`limit` query parameter is present.
"""
request = self.get_request(limit=2)
self.assert_proxied(self.limit_offset_paginator, request)
def test_noncallable_attribute_access(self):
"""
Verify that attempts to access noncallable attributes are proxied to
PageNumberPagination and LimitOffsetPagination.
"""
# Access an attribute unique to PageNumberPagination.
self.assertEqual(
self.proxied_paginator.page_query_param,
self.page_number_paginator.page_query_param
)
# Access an attribute unique to LimitOffsetPagination.
self.assertEqual(
self.proxied_paginator.limit_query_param,
self.limit_offset_paginator.limit_query_param
)
# Access an attribute common to both PageNumberPagination and LimitOffsetPagination.
self.assertEqual(
self.proxied_paginator.display_page_controls,
self.limit_offset_paginator.display_page_controls
)
# Access an attribute found on neither PageNumberPagination nor LimitOffsetPagination.
with self.assertRaises(AttributeError):
zach = self.proxied_paginator
zach.cool # pylint: disable=pointless-statement
......@@ -5,6 +5,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from course_discovery.apps.api import serializers
from course_discovery.apps.api.pagination import ProxiedPagination
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.course_metadata.models import CourseRun, Seat
......@@ -16,6 +17,10 @@ class AffiliateWindowViewSet(viewsets.ViewSet):
renderer_classes = (AffiliateWindowXMLRenderer,)
serializer_class = serializers.AffiliateWindowSerializer
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
def retrieve(self, request, pk=None): # pylint: disable=redefined-builtin,unused-argument
"""
Return verified and professional seats of courses against provided catalog id.
......
......@@ -8,6 +8,7 @@ from rest_framework.decorators import detail_route
from rest_framework.response import Response
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination
from course_discovery.apps.api.renderers import CourseRunCSVRenderer
from course_discovery.apps.api.v1.views import User, prefetch_related_objects_for_courses
from course_discovery.apps.catalogs.models import Catalog
......@@ -24,6 +25,10 @@ class CatalogViewSet(viewsets.ModelViewSet):
queryset = Catalog.objects.all()
serializer_class = serializers.CatalogSerializer
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
@transaction.atomic
def create(self, request, *args, **kwargs):
""" Create a new catalog. """
......
......@@ -6,6 +6,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination
from course_discovery.apps.api.v1.views import get_query_param, PartnerMixin
from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.constants import COURSE_RUN_ID_REGEX
......@@ -24,6 +25,10 @@ class CourseRunViewSet(PartnerMixin, viewsets.ReadOnlyModelViewSet):
queryset = CourseRun.objects.all().order_by(Lower('key'))
serializer_class = serializers.CourseRunWithProgramsSerializer
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
def get_queryset(self):
""" List one course run
---
......
......@@ -4,6 +4,7 @@ from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination
from course_discovery.apps.api.v1.views import prefetch_related_objects_for_courses, get_query_param
from course_discovery.apps.course_metadata.constants import COURSE_ID_REGEX
from course_discovery.apps.course_metadata.models import Course
......@@ -20,6 +21,10 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated,)
serializer_class = serializers.CourseWithProgramsSerializer
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
def get_queryset(self):
q = self.request.query_params.get('q', None)
......
......@@ -3,6 +3,7 @@ from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination
# pylint: disable=no-member
......@@ -17,6 +18,10 @@ class OrganizationViewSet(viewsets.ReadOnlyModelViewSet):
queryset = serializers.OrganizationSerializer.prefetch_queryset()
serializer_class = serializers.OrganizationSerializer
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
def list(self, request, *args, **kwargs):
""" Retrieve a list of all organizations. """
return super(OrganizationViewSet, self).list(request, *args, **kwargs)
......
......@@ -3,6 +3,7 @@ from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination
from course_discovery.apps.api.v1.views import get_query_param
from course_discovery.apps.course_metadata.models import ProgramType
......@@ -16,6 +17,10 @@ class ProgramViewSet(viewsets.ReadOnlyModelViewSet):
filter_backends = (DjangoFilterBackend,)
filter_class = filters.ProgramFilter
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
def get_serializer_class(self):
if self.action == 'list':
return serializers.MinimalProgramSerializer
......@@ -75,3 +80,7 @@ class ProgramTypeListViewSet(mixins.ListModelMixin,
serializer_class = serializers.ProgramTypeSerializer
permission_classes = (IsAuthenticated,)
queryset = ProgramType.objects.all()
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
......@@ -12,7 +12,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import PageNumberPagination
from course_discovery.apps.api.v1.views import PartnerMixin
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
......@@ -27,10 +26,6 @@ class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
lookup_field = 'key'
permission_classes = (IsAuthenticated,)
# NOTE: We use PageNumberPagination because drf-haystack's facet serializer relies on the page_query_param
# attribute, and it is more appropriate for search results than our default limit-offset pagination.
pagination_class = PageNumberPagination
def list(self, request, *args, **kwargs):
"""
Search.
......
......@@ -314,7 +314,7 @@ REST_FRAMEWORK = {
'edx_rest_framework_extensions.authentication.JwtAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PAGINATION_CLASS': 'course_discovery.apps.api.pagination.PageNumberPagination',
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.DjangoModelPermissions',
),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment