I have an issue with DRF, CursorPagination and Filters.
I have an endpoint. When I access the initial page of the enpoint I get a next URL
"next": "http://my-url/api/my-endpoint/?cursor=bz0yMDA%3D&date__gte=2025-04-25T10%3A00%3A00Z&date__lte=2025-04-26T10%3A00%3A00Z"
When I access this URL I get a previous URL
"next": "http://my-url/api/my-endpoint/?cursor=bz00MDA%3D&date__gte=2025-04-25T10%3A00%3A00Z&date__lte=2025-04-26T10%3A00%3A00Z",
"previous": "http://my-url/api/my-endpoint/?cursor=cj0xJnA9MjAyNS0wNC0yNSsxMCUzQTAwJTNBMDAlMkIwMCUzQTAw&date__gte=2025-04-25T10%3A00%3A00Z&date__lte=2025-04-26T10%3A00%3A00Z",
Now when I try to access the previous URL, I get an empty result list.
Here is the code for the endpoint
class RevenuePagination(CursorPagination):
page_size = 200
ordering = ['date', 'custom_channel_name', 'country_name', 'platform_type_code', 'id']
class RevenueFilter(django_filters.FilterSet):
class Meta:
model = Revenue
fields = {
'date': ['lte', 'gte'],
'custom_channel_name': ['exact'],
'country_name': ['exact'],
'platform_type_code': ['exact'],
}
class RevenueViewSet(viewsets.ModelViewSet):
permission_classes = [HasAPIKey]
queryset = Revenue.objects.all()
serializer_class = RevenueSerializer
filterset_class = RevenueFilter
pagination_class = RevenuePagination
@revenue_list_schema()
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
From what I get, the problem seems to be that the previous URL cursor (cj0xJnA9MjAyNS0wNC0yNSsxMCUzQTAwJTNBMDAlMkIwMCUzQTAw
) gets decoded to r=1&p=2025-04-25+10:00:00+00:00
, which is not right, because only the date field is in the cursor, while I have specidied 6 fields in the ordering of the pagination.
I tried it narrowing down the order to ['date', 'id'], but it does not work.
All the fields in the ordering are actual DB fields (DateTime, Char or Integer), no properties.
I am really struggling, tried debugging with Google AI and ChatGPT, both came to a dead end.
EDIT: I went in the DRF source code, and I found that the CursorPagination._get_position_from_instance
only takes into consideration the first field in the ordering list.
def _get_position_from_instance(self, instance, ordering):
field_name = ordering[0].lstrip('-')
if isinstance(instance, dict):
attr = instance[field_name]
else:
attr = getattr(instance, field_name)
return str(attr)
This is really strange, as per documentation the CursorPagination.ordering supports multiple fields. Or am I wrong?
EDIT2: I narrowed the problem down to having date value repeating, and filtering on the date value.
The filter is date__gte=2025-04-25 10:00:00
, and the first 10 pages have the same date value '2025-04-10 10:00:00'. The CursorPagination seems to be taking the first date value from the second page, and using that in the previous cursor. I think it needs something more unique. The combination fields provide uniqueness, but it does not work.
I managed to solve this by using another module `django-cursor-pagination`, and wrapping the paginator from that module in a Pagination class, as required by DRF:
https://pypi.org/project/django-cursor-pagination/
from rest_framework.pagination import BasePagination
from rest_framework.response import Response
from rest_framework.utils.urls import replace_query_param, remove_query_param
from cursor_pagination import CursorPaginator
class CustomCurorPaginator(CursorPaginator):
# we override this method from so that it supports dicts as well
# dicts come up in a queryset when you use aggregations in the ORM
def position_from_instance(self, instance):
position = []
for order in self.ordering:
parts = order.lstrip('-').split('__')
attr = instance
while parts:
key = parts.pop(0)
if isinstance(attr, dict):
attr = attr.get(key)
else:
attr = getattr(attr, key, None)
if attr is None:
position.append(self.none_string)
else:
position.append(str(attr))
return position
class CustomCursorPagination(BasePagination):
page_size = 10
ordering = None
after_query_param = 'after'
before_query_param = 'before'
page_size_query_param = 'page_size'
max_page_size = 100
def get_page_size(self, request):
try:
size = int(request.query_params.get(self.page_size_query_param, self.page_size))
if self.max_page_size:
return min(size, self.max_page_size)
return size
except (TypeError, ValueError):
return self.page_size
def paginate_queryset(self, queryset, request, view=None):
self.request = request
self.queryset = queryset
self.after = request.query_params.get(self.after_query_param)
self.before = request.query_params.get(self.before_query_param)
page_size = self.get_page_size(request)
self.paginator = CustomCurorPaginator(queryset, ordering=self.ordering)
if self.after and self.before:
raise ValueError("Both 'after' and 'before' parameters cannot be used together.")
if self.after:
self.page = self.paginator.page(first=page_size, after=self.after)
elif self.before:
self.page = self.paginator.page(last=page_size, before=self.before)
else:
self.page = self.paginator.page(first=page_size)
self.has_next = self.page.has_next
self.has_previous = self.page.has_previous
self.next_cursor = self.paginator.cursor(self.page[-1]) if self.has_next else None
self.prev_cursor = self.paginator.cursor(self.page[0]) if self.has_previous else None
return list(self.page)
def _build_url_with_cursor(self, param, cursor_value):
url = self.request.build_absolute_uri()
url = remove_query_param(url, self.after_query_param)
url = remove_query_param(url, self.before_query_param)
return replace_query_param(url, param, cursor_value)
def get_next_link(self):
if not self.has_next or not self.next_cursor:
return None
return self._build_url_with_cursor(self.after_query_param, self.next_cursor)
def get_previous_link(self):
if not self.has_previous or not self.prev_cursor:
return None
return self._build_url_with_cursor(self.before_query_param, self.prev_cursor)
def get_paginated_response(self, data):
return Response({
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'results': data
})