1

I have a Django app for contests where a contest can have multiple entries -

Contest in models.py

class Contest(models.Model):
    is_winners_announced = models.BooleanField(default=False)
    ...

ContestEntry in models.py

class ContestEntry(models.Model):
    contest = models.ForeignKey(Contest, on_delete=models.CASCADE,
                                related_name='entries')
    submitted_at = models.DateTimeField(auto_now_add=True)
    assigned_rank = models.PositiveSmallIntegerField(null=True, blank=True)
    ...

In the ContestViewSet, I have a detail route which serves all the entries for a contest -

def pagination_type_by_field(PaginationClass, field):
    class CustomPaginationClass(PaginationClass):
        ordering = field
    return CustomPaginationClass

...

@decorators.action(
    detail=True,
    methods=['GET'],
)
def entries(self, request, pk=None):
    contest = self.get_object()
    entries = contest.entries.all()

    # Order by rank only if winners are announced
    ordering_array = ['-submitted_at']
    if contest.is_winners_announced:
        ordering_array.insert(0, 'assigned_rank')
    pagination_obj = pagination_type_by_field(
        pagination.CursorPagination, ordering_array)()
    paginated_data = contest_serializers.ContestEntrySerializer(
        instance=pagination_obj.paginate_queryset(entries, request),
        many=True,
        context={'request': request},
    ).data

    return pagination_obj.get_paginated_response(paginated_data)

Pagination works fine when winners are not declared for a contest -

GET http://localhost:8000/contests/<id>/entries/

{
    "next": "http://localhost:8000/contests/<id>/entries/?cursor=cD0yMDIwLTAyLTE3KzIwJTNBNDQlM0EwNy4yMDMyMTUlMkIwMCUzQTAw",
    "previous": null,
    "results": [  // Contains all objects and pagination works
        {...},
        ...
    ]
}

But when the winners are announced, pagination breaks:

GET http://localhost:8000/contests/<id>/entries/

{
    "next": "https://localhost:8000/contests/4/entries/?cursor=bz03JnA9Mw%3D%3D",
    "previous": null,
    "results": [  // Contains all objects only for the first page; next page is empty even when there are more entries pending to be displayed
        {...},
        ...
    ]
}

The strange thing I see here is that cursor in the second case looks different from what it normally looks like.

1 Answers1

0

Finally found a solution to the problem.

The pagination fails because of null values in the most significant ordering field (i.e. assigned_rank).

When going to the next page, cursor pagination tries to compute next rows from the database based on the lowest value from the previous page -

if self.cursor.reverse != is_reversed:
    kwargs = {order_attr + '__lt': current_position}
else:
    kwargs = {order_attr + '__gt': current_position}

queryset = queryset.filter(**kwargs)

Internal Implementation

Due to this, all the rows are filtered out.


To prevent this, we can put up a fake rank which will not be None and will not affect the ordering in case if the actual rank is None. This fallback rank can be max(all_ranks) + 1 -

from django.db.models.functions import Coalesce
...

@decorators.action(
    detail=True,
    methods=['GET'],
)
def entries(self, request, pk=None):
    contest = self.get_object()
    entries = contest.entries.all()

    ordering = ('-submitted_at',)
    if contest.is_winners_announced:
        max_rank = entries.aggregate(
            Max('assigned_rank')
        )['assigned_rank__max']
        next_rank = max_rank + 1
        entries = entries.annotate(
            pseudo_rank=Coalesce('assigned_rank', next_rank))
        ordering = ('pseudo_rank',) + ordering
    ...  # same as before

Read more about Coalesce.

This would result in setting rank for all the entries to 1 more than the worst-assigned rank entry. For example, if we have ranks 1, 2 and 3 assigned, the pseudo_rank for all other entries will be 4 and they will be ordered by -submitted_at.

And then as they say, "It worked like charm ✨".