Letting ListView gracefully handle out-of-range page numbers

I’d like to register my support for the rather simple feature request in #33233 (closed as a duplicate of #9798, a wontfix). The reporter also posted in django-developers but didn’t get any discussion there.

As noted in the above links, the stated resolution in #9798 of “write your own wrapper function” is cumbersome due to the way MultipleObjectMixin is written. The least verbose way I’ve seen this done (by overriding Paginator.validate_number(), as seen in this Stack answer) affects quite a few other pagination methods. A more targeted overriding of MultipleObjectMixin.paginate_queryset() seems impossible to do concisely (we really just want to change the paginator.page() call to paginator.get_page()).

Also, as the reporter also noted, it’s inconsistent that MultipleObjectMixin already has an allow_empty option, but the suggested solution of an allow_out_of_range attribute is rejected.

To share a use case from my own experience for this feature: on a classic static-HTML project (no REST APIs, no HTMX), I have a paginated ListView of objects that allows the user to perform actions on individual objects on the page which may or may not cause the objects to be removed from this ListView. The actions are implemented so that after performing the POST action, the user is redirected to the same page (through a “next” hidden URL input that includes the page parameter); this is a much user-friendlier flow than, say, redirecting the user to some detail page after the action, or to the first page of the list. However, because the list of objects can be reduced as the user repeatedly performs the action, eventually they could get a page number that 404s. What I need is for ListView or MultipleObjectMixin to be more graceful in these cases by serving a valid last page instead of a 404 (in the spirit of Paginator.get_page()).

Note that the special last page keyword does not help as the user could start performing the actions from a non-last page and still eventually get the 404.

Thank you!

Hi @djramones

I’m not quite following you:

Paginator.get_page() already returns the last page for an invalid page number — so I’m not sure what you’re asking for exactly?

Update: OK, I see, ListView isn’t using get_page()

        page = paginator.page(page_number)

… we really just want to change the paginator.page() call to paginator.get_page()

So is the solution not to set paginator_class to a Paginator subclass that proxies page to get_page()? :thinking:

The change from paginator.page() to paginator.get_page() has to happen in MultipleObjectMixin.paginate_queryset() (particularly this line) but I’m not sure if that can be overridden concisely in a MultipleObjectMixin or ListView subclass.

The other approach, using a Paginator subclass as you said, is the most concise solution I know:

class GracefulPaginator(Paginator):
    """From https://stackoverflow.com/a/40835335/14354604."""
    def validate_number(self, number):
        try:
            return super().validate_number(number)
        except EmptyPage:
            if number > 1:
                return self.num_pages
            raise

class GracefulListView(ListView):
    paginator_class = GracefulPaginator

But it works with a method (validate_number) that also affects other behavior (such as get_elided_page_range). Admittedly that might not be an issue though.

The proposal I think is to update the line in MultipleObjectMixin.paginate_queryset() to be something like this:

if self.get_allow_out_of_range():
    page = paginator.get_page(page_number)
else:
    page = paginator.page(page_number)

(plus a simple get_allow_out_of_range getter)

So that in an app only the following is needed:

class GracefulListView(ListView):
    allow_out_of_range = True

I think I’d probably adjust page itself (with the get_page() logic…):

class GracefulPaginator(Paginator):
    def page(self, number):
        try:
            number = self.validate_number(number)
        except PageNotAnInteger:
            number = 1
        except EmptyPage:
            number = self.num_pages
        return super().page(number)
            
            
class GracefulListView(ListView):
    paginator_class = GracefulPaginator

I’m not sure it’s worth complicating the GCBV API for, which is already overly complex, so I’d sort of rest at +0, but, yes, it’s not as nice as it could be.

The change looks small enough. Let’s see what others think.

1 Like

I’ve been bitten by this too, and would advocate for replacing .page() by .get_page in ListView, or as a minimal step, to allow for better subclassing capability to make that change easily possible in subclasses.

Do you think it would be too much backwards incompatible to make that replacement in ListView? In my opinion, the get_page behavior sounds as the more natural way of handling wrong page numbers and would better fit the Django mantra.

Personally I prefer the behavior of page(). Returning valid data for multiple URLs is bad for SEO (and even if you don’t care about SEO it simply feels wrong) and might lead to wrong links etc…