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.
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)
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
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 ✨".