skip to Main Content

I have a model “Category” with a ForeignKey to “parent_category”.
How can I order this model in the Django admin list view like:

- category 1
-- subcategory 1 of category 1
--- subsubcategory 1 of subcategory 1 of category 1
-- subcategory 2 of category 1
-- subcategory 3 of category 1
- category 2
-- subcategory 1 of category 2
-- subcategory 2 of category 2

I have tried the following but this won’t work. So I need some help to order the function ‘get_relative_name’.

class PrivateContentCategory(models.Model):
    name = models.CharField(
        max_length=250,
        verbose_name=_('Naam'),
    )
    slug = models.SlugField(
        verbose_name=_('Url'),
        blank=True,
    )
    parent_category = models.ForeignKey(
        'self',
        on_delete=models.SET_NULL,
        related_name='child_category_list',
        verbose_name=_('Hoofdcategorie'),
        blank=True,
        null=True,
    )

    def __str__(self):
        str = self.name
        parent_category_obj = self.parent_category
        while parent_category_obj is not None:
            str = parent_category_obj.name + ' --> ' + str
            parent_category_obj = parent_category_obj.parent_category
        return str

    def get_relative_name(self):
        str = self.name
        parent_category_obj = self.parent_category
        while parent_category_obj is not None:
            str = '--' + str
            parent_category_obj = parent_category_obj.parent_category
    get_relative_name.short_description = _('Naam')
    get_relative_name.admin_order_field = [
        'parent_category__parent_category',
        'name',
    ]

EDIT!!!
The names of the parent category should not be displayed with the category. I had written it like this to display how the model should be ordered. The display of the list will just be:

- OS
-- Windows
--- Windows 7
--- Windows 8
--- Windows 10
-- Mac
-- Linux
--- Debian
---- Ubuntu
--- Fedora
---- CentOS
---- Oracle Linux

3

Answers


  1. Chosen as BEST ANSWER

    What worked for me was to add a new field "absolute_name" to the model which will be auto populated with a pre_save signal. After an instance is saved, this field will conain the names for all parent_categories of the instance before it own name. At last, I just needed to order the instance on this field:

    class PrivateContentCategory(models.Model):
        name = models.CharField(
            max_length=250,
            verbose_name=_('Naam'),
        )
        slug = models.SlugField(
            verbose_name=_('Url'),
            blank=True,
        )
        parent_category = models.ForeignKey(
            'self',
            on_delete=models.SET_NULL,
            related_name='child_category_list',
            verbose_name=_('Hoofdcategorie'),
            blank=True,
            null=True,
        )
        absolute_name = models.TextField(
            verbose_name=_('Absolute naam'),
            blank=True,
        )
    
        def __str__(self):
            return self.absolute_name
    
        def get_relative_name(self):
            str = self.name
            parent_category_obj = self.parent_category
            while parent_category_obj is not None:
                str = '--' + str
                parent_category_obj = parent_category_obj.parent_category
            return str
        get_relative_name.short_description = _('Naam')
        get_relative_name.admin_order_field = [
            'absolute_name',
        ]
    
        class Meta:
            verbose_name = _('Privé inhoud categorie')
            verbose_name_plural = _('Privé inhoud categorieën')
            ordering = [
                'absolute_name',
            ]
    
    
    @receiver(models.signals.pre_save, sender=PrivateContentCategory)
    def pre_save_private_content_category_obj(sender, instance, **kwargs):
        # START Generate instance.absolute_name
        instance.absolute_name = instance.name
        parent_category_obj = instance.parent_category
        while parent_category_obj is not None:
            instance.absolute_name = parent_category_obj.name + ' --> ' + instance.absolute_name
            parent_category_obj = parent_category_obj.parent_category
        # END Generate instance.absolute_name
    

  2. In order to be able to order by it, you need to annotate the queryset in the modeladmin, so a method on the model won’t help.

    admin.py

    from django.db.models.expressions import F
    ...
    
    
    @admin.register(PrivateContentCategory)
    class PrivateContentCategoryAdmin(admin.ModelAdmin):
        list_display = (
            'name',
            'relative_name',
        )
    
        def get_queryset(self, request):
            qs = super().get_queryset(request)  # type: QuerySet
            qs = qs.annotate(relative_name=F('name'))  # for now :)
            return qs
    
        def relative_name(self, obj: PrivateContentCategory):
            return obj.relative_name
    
        relative_name.admin_order_field = 'relative_name'
    
    

    This will add a column to the admin, and allow you to click-sort it.

    One thing this will not allow you to do it to have a default sort on that column. This will fail:

    class PrivateContentCategoryAdmin(admin.ModelAdmin):
       ...
       ordering = ('relative_name',)
    

    ERRORS:
    <class ‘cats.admin.PrivateContentCategoryAdmin’>: (admin.E033) The value of ‘ordering[0]’ refers to ‘relative_name’, which is not an attribute of ‘cats.PrivateContentCategory’.

    This is a long-standing bug in Django: https://code.djangoproject.com/ticket/17522
    There are ways around it, but I am getting off topic…

    So the second problem is, obviously, we need to construct the relative names there, instead of that F('name'). I could be wrong, but I think the only DB engine that supports this on-the-fly is Postgres. If you are using a different DB engine, then I guess you are left with having to denormalize your data a bit and have a column with full parent name on every child.

    There could be better ways to do this, but here is how I have done it:

    admin.py

    ...
    from django.db.models.expressions import RawSQL
    
    
    relative_name_query = '''
        WITH RECURSIVE "relative_names" as (
            SELECT "id", "parent_category_id", CAST("name" AS TEXT)
            FROM "{table}"
            WHERE "parent_category_id" IS NULL
            UNION ALL
            SELECT "t"."id", "t"."parent_category_id", CONCAT_WS('/', "r"."name", "t"."name")
            FROM "{table}" "t"
            JOIN "relative_names" "r" ON "t"."parent_category_id" = "r"."id"
        )
        SELECT "name"
        FROM "relative_names" WHERE "relative_names"."id" = "{table}"."id"
    '''
    
    
    @admin.register(PrivateContentCategory)
    class PrivateContentCategoryAdmin(admin.ModelAdmin):
            ...
    
            # instead of that F('name') line:
            qs = qs.annotate(relative_name=RawSQL(
                relative_name_query.format(
                    table=qs.model._meta.db_table,
                ),
                (),
            ))
    
    

    P.S.

    Oracle seems to support it as well, though with a different syntax: SQL recursive query on self referencing table (Oracle)

    P.P.S.

    If you end up having to keep parent name on the model, then the annotate looks something like this:

    qs = qs.annotate(relative_name=Concat(F('parent_name'), Value('/'), F('name')))
    

    P.P.P.S.

    It is possible to add two annotations, one for displaying values and another for sorting. Actually, looking at your question again, I think this will be needed because your example has subcat -- cat and not cat -- subcat as I assumed above. For this we need two annotations, one of which will be returned from relative_name modeladmin method and the other will be for relative_name.admin_order_field.

    Login or Signup to reply.
  3. A much cleaner, more efficient solution is to use django-mptt:

    from mptt.models import MPTTModel
    from mptt.fields import TreeForeignKey
    
    class PrivateContentCategory(MPTTModel):
        name = models.CharField(max_length=250)
        slug = models.SlugField(blank=True)
        parent_category = TreeForeignKey(
            'self',
            on_delete=models.SET_NULL,
            related_name='child_category_list',
            blank=True,
            null=True,
        )
    
        class MPTTMeta:
            order_insertion_by = ['name']
    

    If you want to generate a <select> dropdown in forms using this model:

    from mptt.forms import TreeNodeMultipleChoiceField
    
    class SomeForm(forms.Form):
        category = TreeNodeMultipleChoiceField(
            queryset = PrivateContentCategory.objects.all()
        )
    

    This also works in the admin:

    from mptt.admin import MPTTModelAdmin
    
    class PrivateContentCategoryAdmin(MPTTModelAdmin):
        mptt_level_indent = 20
    
    admin.site.register(PrivateContentCategory, PrivateContentCategoryAdmin)
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search