djangodjango-rest-frameworkpaginationdjango-pagination

Django Rest Framework Cursos pagination with multiple ordering fields and filters


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.


Solution

  • 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
            })