More Charts in the Django Admin

Posted on Mon 09 November 2020 in Posts

So in my day job I do a decent amount of work in Django, and have recently been getting into customizing the Django admin page. If you've never done much with the Django admin, the docs are definitely worth checking out, and once you've gone through that the Django Admin Cookbook is also an excellent resource for tips on ways to get even more out of the Django admin site. Having said that, I wanted to get some pretty graphs like all the cool kids in Javascript-land are doing.

I stumbled across this awesome blog post by Dani Hodovic that is a great example of how to use Chart.js to throw a basic bar chart into the Django admin. There's one problem though: the chart doesn't respect any filters you have in the Django admin, instead only graphing the entire dataset. What I want is if you (as an example) had a date_hierarchy set up on your ModelAdmin, then as you pick date filters the graph re-renders to only graph the filtered data. Lets see how we might do that. As a starting point, I'm going with the code from that blog post, which is on Github at: https://github.com/danihodovic/django_admin_chart_js, specifically I'm going to be working from the current master which (at the time of writing) is SHA d8fc3a68ad68f3bea139b0a4c888f006ef97c286.

Setting Up the Filter

First off, we need some filters on the EmailSubscribers model, so lets add that:

class EmailSubscriberAdmin(admin.ModelAdmin):
    date_hierarchy = "created_at"

    ... rest of the code as it exists ...

If you've never done anything with the Django admin before, this might seem like magic, 1-line to get a nice date filter in place:

Date Filters in the Django Admin

As you click those filters, the changeset view will adjust to only those records which meet that criteria. All this for 1-line of code. Yup, Django's pretty sweet.

Getting the Filters

So first problem with adjusting the graph to only those rows that are selected is to figure out how to get what filters are currently in place. As it turns out, you can get this from the result of the changelist_view() function in your ModelAdmin via the context_data mapping:

def changelist_view(self, request, extra_context=None):
    response = super().changelist_view(request, extra_context=extra_context)
    queryset = response.context_data["cl"].queryset

At this point queryset is a regular Django QuerySet object with the filters that have been applied in the current changelist view. This is exactly what we need, as the chart_data function from original blog post just does a few transformations (annotations) on a QuerySet object that was just all EmailSubscriber objects. The original function looked like:

def chart_data(self):
    return (
        EmailSubscriber.objects.annotate(date=TruncDay("created_at"))
        .values("date")
        .annotate(y=Count("id"))
        .order_by("-date")
    )

So if we replace EmailSubscriber.objects with the queryset, we get exactly what we want:

@admin.register(EmailSubscriber)
class EmailSubscriberAdmin(admin.ModelAdmin):
    ... code omitted for brevity ...

    def changelist_view(self, request, extra_content=None):
        response = super().changelist_view(request, extra_context=extra_context)
        queryset = response.context_data["cl"].queryset
        chart_data = self.chart_data(queryset)
        as_json = json.dumps(list(chart_data), cls=DjangoJSONEncoder)
        response.context_data.update({"chart_data": as_json})
        return response

    ... other functions unchanged ...

    def chart_data(self, queryset):
        return (
            queryset.annotate(date=TruncDay("created_at"))
            .values("date")
            .annotate(y=Count("id"))
            .order_by("-date")
        )

And bingo, now as we click some of the date hierarchy filters the graph re-renders with only the selected rows:

A Wrinkle

One problem: the "Reload Chart data" button now breaks, because it's still trying to call chart_data() without supplying a QuerySet object. That's an easy fix though, just modify the chart_data_endpoint() function to call chart_data() with the same QuerySet the function used to use inside it -- EmailSubscriber.objects:

    def chart_data_endpoint(self, request):
        chart_data = self.chart_data(EmailSubscriber.objects)
        return JsonResponse(list(chart_data), safe=False)

Now clicking "Reload Chart data" reverts the graph back to showing all records, leaving the filters in place.