Mixin adding sort facility to ListView

Hello, I’ve just started with Django and I’m also new to Python (I have experience in web development with other languages/frameworks).

I started by using the built-in class-based view to list objects (ListView) but I’ve been surprised by the absence of a built-in facility to manage sorting automatically. It is mandatory that it works together with the existing pagination tool.
I don’t want to use an external package to implement such a basic function since Django should be “Batteries-Included”, so I solved writing a Mixin (code and explanation below).

Since I’m a newbie in Django/Python I’d like to hear your opinions about my code.
Thank you for your comments and suggestions!

A simple class to wrap sort field value and direction:

class SortField:
    SORT_DIR_ASC = "asc"
    SORT_DIR_DESC = "desc"

    def __init__(self, sort_by, sort_dir=SORT_DIR_ASC):
        self.sort_by = sort_by
        self.sort_dir = sort_dir

    def __repr__(self):
        return f"SortField({self.sort_by}, {self.sort_dir})"

The Mixin:

class SortableListMixin:
    default_sort_field = None  # override to set default sorting data
    q_sort_by = "qSortBy"  # query string param name for the field
    q_sort_dir = "qSortDir"  # query string param name for sort direction (ascending/descending)

    def get_default_sort_field(self) -> SortField:
        return self.default_sort_field

    def get_ordering(self):
        sf = self.get_default_sort_field()
        orderby = self.request.GET.get(self.q_sort_by, sf.sort_by)
        sort_direction = self.request.GET.get(self.q_sort_dir, sf.sort_dir)

        # The negative sign in front the value indicates descending order.
        if sort_direction == SortField.SORT_DIR_DESC:
            orderby = "-" + orderby
        return orderby

    # keep current sorting url (query string) while paging
    # Example:
    #   <a href="?page={{ page_obj.next_page_number }}&{{ ctx_sort_qstring }}">next</a>
    #   <a href="?page={{ page_obj.paginator.num_pages }}&{{ ctx_sort_qstring }}">last</a>
    def get_context_data(self, **kwargs):
        sf = self.get_default_sort_field()
        context = super().get_context_data(**kwargs)
        sort_by = self.request.GET.get(self.q_sort_by, sf.sort_by)
        sort_dir = self.request.GET.get(self.q_sort_dir, sf.sort_dir)
        context['ctx_orderby'] = sort_by
        context['ctx_sort'] = sort_dir
        context['ctx_sort_qstring'] = "&".join(
            ["=".join([self.q_sort_by, sort_by]), "=".join([self.q_sort_dir, sort_dir])])
        return context

ListView exetnsion with MIxin:

class CustomersListView(SortableListMixin, ListView):
    template_name = "pages/customers_list.html"
    model = Customer
    paginate_by = 10
    default_sort_field = SortField('lastname')

Interesting part of the template:

<table>
      <thead>
        <tr>
          <th>Lastname
              {% if ctx_orderby != "lastname" or ctx_orderby == "lastname" and ctx_sort == "desc" %}
              <a href="?qSortBy=lastname&qSortDir=asc">&#x25B2;</a>&nbsp;
              {% endif %}
              {% if ctx_orderby == "lastname" and ctx_sort == "asc" %}
              <a href="?qSortBy=lastname&qSortDir=desc">&#x25BC;</a>
              {% endif %}
          </th>
      ...omissis...
{% if is_paginated %}
                <div class="pagination">
                    <span class="step-links">
                        {% if page_obj.has_previous %}
                            <a href="?page=1&{{ ctx_sort_qstring }}">&laquo; first</a>
                            <a href="?page={{ page_obj.previous_page_number }}&{{ ctx_sort_qstring }}">previous</a>
                        {% endif %}

                        <span class="current">
                            [ Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} ]
                        </span>

                        {% if page_obj.has_next %}
                            <a href="?page={{ page_obj.next_page_number }}&{{ ctx_sort_qstring }}">next</a>
                            <a href="?page={{ page_obj.paginator.num_pages }}&{{ ctx_sort_qstring }}">last &raquo;</a>
                        {% endif %}
                    </span>
                </div>
              {% endif %}

Basically I used GET query string for passing required values for sorting, then I use them in the “get_ordering” to compute the “order by” clause.
Then, in “get_context_data” I set those values in the context to send them back and reuse in future requests.

You probably want to add some error checking on those GET parameters. You’ll want to ensure that the user doesn’t change the url, causing a 500 error to be thrown in your view.

<opinion>
This is a sub-optimal position to hold on the basis of the reason you’ve specified.
</opinion>

One of the real strengths of the Django/Python environment is how easy it is to extend by the addition of external libraries.

There is nothing “less-efficient” or “improper” about using an external package to provide specific or targeted functionality - and in fact, there are many reasons why an external package is better than what’s included in Django.

External packages have the benefit of being able to “move faster” than Django itself. Changes needed to Django are released on a timetable, where a third-party package can more quickly evolve and respond to changing requirements.

Additionally, a popular third-party package would have the advantage of being used in multiple situations, hopefully making it more robust by addressing situations you may not initially think of. (Your lack of error checking of the sort parameters is one such example.)

Yes, there’s always a decision point between “build or buy” - but choosing to “build” should depend upon the technical requirements and the quality of the packages available, not based simply upon where they reside.

Hi @KenWhitesell , thanks for reply.

You probably want to add some error checking on those GET parameters. You’ll want to ensure that the user doesn’t change the url, causing a 500 error to be thrown in your view.

My code fall-backs to default sort if query string params don’t match the expected or are missing.
Do you mean I should ensure sorting column value in GET param will be in a allowed list (e.g. only “firstname” and “lastname”) ?

One of the real strengths of the Django/Python environment is how easy it is to extend by the addition of external libraries.

You re right “widely speaking”, but I think is curios choosing to provide a generic list view like the “ListView” with a paginator out of the box, but not a sorting facility!
I think that “sorting” is commonly used as well as the pagination on object list management.

I’d like to read your opinion on this, thanks!

Or, more generally, that they are even fields in that model - and perhaps that they are not some multi-foreign key chain to some unexpected model. (If you try to issue a query containing an order_by function call with an invalid field name, it will throw an error.)

That sort of sorting facility has a ton of potential edge cases and error conditions. Yes, it’s useful. No, I don’t believe it needs to be in core.

It appears to me that you’re making an unwarranted judgement call on what’s in core vs what isn’t. Not everything needs to be (nor should be) in core.

It just doesn’t matter if it’s in core or a third-party library.