How to implement sorting in Django Admin for calculated model properties without writing the logic twice?

TL/DR: Yes your solution seems to follow the only way that makes sense.


Well, what you have composed here seems to be the recommended way from the sources you list in your question and for good reason.

What is the good reason though?
I haven't found a definitive, in the codebase, answer for that but I imagine that it has to do with the way @property decorator works in Python.

When we set a property with the decorator then we cannot add attributes to it and since the admin_order_field is an attribute then we can't have that in there. That statement seems to be reinforced from the Django Admin's list_display documentation where the following passage exists:

Elements of list_display can also be properties. Please note however, that due to the way properties work in Python, setting short_description on a property is only possible when using the property() function and not with the @property decorator.

That quote in combination with this QA: AttributeError: 'property' object has no attribute 'admin_order_field' seems to explain why it is not possible to have an orderable from a model property directly into the admin panel.


That explained (probably?) it is time for some mental gymnastics!!

In the previously mentioned part of the documentation we can also see that the admin_order_field can accept query expressions since version 2.1:

Query expressions may be used in admin_order_field. For example:

from django.db.models import Value
from django.db.models.functions import Concat

class Person(models.Model):
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)

    def full_name(self):
        return self.first_name + ' ' + self.last_name
    full_name.admin_order_field = Concat('first_name', Value(' '), 'last_name')

That in conjunction with the previous part about the property() method, allows us to refactor your code and essentially move the annotation part to the model:

class De(models.Model):
    ...
    def calculate_s_d(self):
        if self.fr:
            return self.de
        else:
            return self.gd + self.na

    calculate_s_d.admin_order_field = Case(
        When(fr=True, then='s_d'),
        When(fr=False, then=F('gd') + F('na')),
        default=Value(0),
        output_field=IntegerField(),
    )

    s_d = property(calculate_s_d)

Finally, on the admin.py we only need:

class DeAdmin(admin.ModelAdmin):
    list_display = ("[...]", "s_d")

Although I think your solution is very good (or even better), the another approach can be to extract admin query to the model manager:

class DeManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().annotate(
            s_d=Case(
                When(fr=True, then='s_d'),
                When(fr=False, then=F('gd') + F('na')),
                default=Value(0),
                output_field=IntegerField(),
            )
        )


class De(models.Model):
    fr = models.BooleanField("[...]")
    de = models.SmallIntegerField("[...]")
    gd = models.SmallIntegerField("[...]")
    na = models.SmallIntegerField("[...]")
    objects = DeManager()


class DeAdmin(admin.ModelAdmin):
    list_display = ("[...]", "s_d", "gd", "na", "de", "fr" )

In this case you don't need the property because each object will have s_d attribute, although this is true only for existing objects (from the database). If you create a new object in Python and try to access obj.s_d you will get an error. Another disadvantage is that each query will be annotated with this attribute even if you don't use it, but this can be solved by customizing the manager's queryset.


Unfortunately, this is impossible in current stable Django version (up to 2.2) due to Django admin not fetching admin_order_field from object properties.

Fortunately, it will be possible in upcoming Django version (3.0 and up) which should be released on 2nd of December.

The way to achieve it:

class De(models.Model):

    fr = models.BooleanField("[...]")
    de = models.SmallIntegerField("[...]")
    gd = models.SmallIntegerField("[...]")
    na = models.SmallIntegerField("[...]")
    # [several_attributes, Meta, __str__() removed for readability]

    def s_d(self):
        if self.fr:
            return self.de
        else:
            return self.gd + self.na
    s_d.admin_order_field = '_s_d'
    s_d = property(s_d)

Alternatively, you can create some decorator that will add any attribute to function, before converting it to property:

def decorate(**kwargs):
    def wrap(function):
        for name, value in kwargs.iteritems():
            setattr(function, name, value)

        return function
    return wrap

class De(models.Model):

    fr = models.BooleanField("[...]")
    de = models.SmallIntegerField("[...]")
    gd = models.SmallIntegerField("[...]")
    na = models.SmallIntegerField("[...]")
    # [several_attributes, Meta, __str__() removed for readability]

    @property
    @decorate(admin_order_field='_s_d')
    def s_d(self):
        if self.fr:
            return self.de
        else:
            return self.gd + self.na