pincoin
pincoin

Reputation: 765

django admin list_display with count annotation

I'd like to add "annotation count" field into list_display in Django Admin.

models.py

class Log(models.Model):
    ...
    ip_address = models.GenericIPAddressField(
        verbose_name=_('IP address'),
        db_index=True,
    )
    ...

admin.py

class LogAdmin(admin.ModelAdmin):
    list_display = (..., 'ip_address', 'ip_address_count', ...)

    def ip_address_count(self, instance):
        return models.Log.objects \
            .filter(ip_address=instance.ip_address) \
            .count()

    ip_address_count.short_description = _('IP Address Count')

It works fine with "LOTS OF" SQL queries.

I'd like to improve my admin.py like this:

class Log(models.Model):
    ...

    def get_queryset(self, request):
        qs = super(LogAdmin, self).get_queryset(request)
        qs = qs.values('ip_address') \
               .annotate(ip_address_count=Count('ip_address'))
        return qs

    def ip_address_count(self, instance):
        return instance.ip_address_count

However, I encountered the error messages:

'dict' object has no attribute '_meta'

It seems that I found the reason why the error occurs:

Django Admin, can't groupe by: Exception Value: 'dict' object has no attribute '_meta'

However, it does not help me to solve the problem.

Thank you for your answers.

Edit: It works wrong if I don't append .values('ip_address).

It does not group by ip_address. It group by all the fields group by field1, field2, ip_address, ... Therefore, it results in "1" all the cases.

Upvotes: 4

Views: 2593

Answers (2)

Cédric Paquot
Cédric Paquot

Reputation: 121

Here is how you can do natively. Django knows how to handle this (see the documentation about the admin and list_display).

class LogAdmin(admin.ModelAdmin):
    list_display = (..., "field1", "field2"... "quantity_of_things",)

    def get_queryset(self, request):
        return (super(LogAdmin, self).get_queryset(request).annotate(quantity_of_things=Count("xxxxxx")))

    def quantity_of_things(self, obj):
        return obj.quantity_of_things
    quantity_of_things.short_description = "Number of xxxxxx"

You annotate the queryset with anything you need (a Count for instance). As you can see, you just have to use the same name 3 times (quantity_of_things in my example)

  1. in the list_display (Django understands this is a callable),
  2. in the annotate,
  3. in the function to be called.
  4. quantity_of_things.short_description is for the column title

Upvotes: 7

not2acoder
not2acoder

Reputation: 1152

That is because the moment you apply values to the queryset, it returns a list of dictionary, whereas the admin is expecting a queryset of objects.

I have been in this situation before, so I did something like this:

def changelist_view(self, request, extra_context=None):
    response = super().changelist_view(request, extra_context)
    try:
        # this is the final queryset to be rendered on the page after pagination.
        _cl = response.context_data['cl']
        qs = _cl.result_list._clone()
        # since in my case mysql 5.5 does'nt support subquery with LIMIT
        # fetch all the ips on that page
        ips = list(set([obj.ip_address for obj in qs]))
        result_qs = models.Log.objects.values('ip_address') \
                .filter(ip_address__in=ips) \
                .annotate(ip_address_count=Count('ip_address'))
        result = {_r['ip_address']: _r['ip_address_count'] for _r in result_qs}
        setattr(self, '_ip_addr_count', result)
    except:
        pass
    return response

def ip_address_count(self, instance):
    # fetch the count from the dict we set above
    return self._ip_addr_count.get(instance.ip_address)

Hence you see what I did is, fetch the ips from the final queryset and then using that to query the count. By doing this you will query the database only once per page.

I hope you get the basic underlying idea.Please suit it according to your need.

Upvotes: 1

Related Questions