DEV Community

Cover image for How I Added Live Charts to the Django Admin Without Installing a Single Package
Vicente G. Reyes
Vicente G. Reyes

Posted on • Originally published at vicentereyes.org

How I Added Live Charts to the Django Admin Without Installing a Single Package

The Django admin is a powerful tool that most developers underuse. Out of the box it gives you a filterable, searchable table for every model — but it stops short of anything visual. If you want to see trends over time, you're staring at rows.

I recently added a PageVisit model to my portfolio backend to track which blog posts, projects, and services people actually read. After 73 visits in two days I realised most of them were bots — but that problem aside, I also wanted a fast way to see visit trends without leaving the admin. Here's how I wired up three Chart.js charts with no new Python dependencies.


The Setup

The PageVisit model records one row per visit:

class PageVisit(models.Model):
    PAGE_TYPE_CHOICES = [
        ("blog_post", "Blog Post"),
        ("project", "Project"),
        ("service", "Service"),
    ]
    DEVICE_TYPE_CHOICES = [
        ("desktop", "Desktop"),
        ("mobile", "Mobile"),
        ("tablet", "Tablet"),
        ("bot", "Bot"),
        ("unknown", "Unknown"),
    ]

    page_type = models.CharField(max_length=20, choices=PAGE_TYPE_CHOICES)
    object_id = models.PositiveIntegerField()
    ip_address = models.GenericIPAddressField(null=True, blank=True)
    timestamp = models.DateTimeField(default=timezone.now)
    browser = models.CharField(max_length=100, blank=True, default="")
    os = models.CharField(max_length=100, blank=True, default="")
    device_type = models.CharField(max_length=10, choices=DEVICE_TYPE_CHOICES, blank=True, default="")
Enter fullscreen mode Exit fullscreen mode

The goal: a bar chart of daily visits, a doughnut of visits by page type, and a doughnut of visits by device — all rendered above the standard changelist table, with a 7d / 30d / 90d toggle on the bar chart.


Step 1 — Inject Chart Data via changelist_view

Django admin's ModelAdmin exposes a changelist_view method you can override to inject arbitrary context. The trick is to query your aggregates there and pass them as JSON so the template can hand them straight to Chart.js.

import json
from datetime import timedelta

from django.contrib import admin
from django.db.models import Count
from django.db.models.functions import TruncDate
from django.utils import timezone

from .models import PageVisit


@admin.register(PageVisit)
class PageVisitAdmin(admin.ModelAdmin):
    # ... list_display, list_filter, etc.

    def changelist_view(self, request, extra_context=None):
        try:
            period = int(request.GET.get("days", 30))
            if period not in (7, 30, 90):
                period = 30
        except (ValueError, TypeError):
            period = 30

        # Django admin's ChangeList rejects unknown query params and redirects.
        # Strip 'days' before super() sees it.
        if "days" in request.GET:
            params = request.GET.copy()
            params.pop("days")
            request.GET = params

        since = timezone.now() - timedelta(days=period - 1)

        daily = (
            PageVisit.objects.filter(timestamp__gte=since)
            .annotate(date=TruncDate("timestamp"))
            .values("date")
            .annotate(count=Count("id"))
            .order_by("date")
        )
        by_type = (
            PageVisit.objects.values("page_type")
            .annotate(count=Count("id"))
            .order_by("page_type")
        )
        by_device = (
            PageVisit.objects.values("device_type")
            .annotate(count=Count("id"))
            .order_by("device_type")
        )

        extra_context = extra_context or {}
        extra_context["chart_period"] = period
        extra_context["chart_daily_labels"] = json.dumps(
            [row["date"].strftime("%b %d") for row in daily]
        )
        extra_context["chart_daily_data"] = json.dumps(
            [row["count"] for row in daily]
        )
        extra_context["chart_type_labels"] = json.dumps(
            [row["page_type"] for row in by_type]
        )
        extra_context["chart_type_data"] = json.dumps(
            [row["count"] for row in by_type]
        )
        extra_context["chart_device_labels"] = json.dumps(
            [row["device_type"] for row in by_device]
        )
        extra_context["chart_device_data"] = json.dumps(
            [row["count"] for row in by_device]
        )

        return super().changelist_view(request, extra_context=extra_context)
Enter fullscreen mode Exit fullscreen mode

One non-obvious gotcha: Django admin's ChangeList treats any unrecognised query parameter as an invalid filter and issues a redirect to strip it. If you pass ?days=7 directly to super().changelist_view(), Django silently redirects to the clean URL and your parameter disappears. The fix is to read days from request.GET first, then pop it so super() never sees it.


Step 2 — Override the Changelist Template

Django's template loading checks templates/admin/<app_label>/<model_name>/change_list.html before falling back to the built-in. Create that file and extend the default:

portfolio_backend_v2/
  templates/
    admin/
      analytics/
        pagevisit/
          change_list.html   ← this file
Enter fullscreen mode Exit fullscreen mode

The template loads Chart.js from CDN, renders three chart boxes above {{ block.super }} (which outputs the normal table), and reads the injected context variables:

{% extends "admin/change_list.html" %}

{% block content %}
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
  <style>
    .pv-charts {
      display: grid;
      grid-template-columns: 2fr 1fr 1fr;
      gap: 1.5rem;
      margin-bottom: 2rem;
      align-items: start;
    }
    /* ... */
  </style>

  <div class="pv-charts">
    <div class="pv-chart-box pv-chart-bar">
      <div class="pv-chart-box-header">
        <h3>Visits</h3>
        <div class="pv-period-toggle">
          <a href="?days=7"  class="{% if chart_period == 7  %}active{% endif %}">7d</a>
          <a href="?days=30" class="{% if chart_period == 30 %}active{% endif %}">30d</a>
          <a href="?days=90" class="{% if chart_period == 90 %}active{% endif %}">90d</a>
        </div>
      </div>
      <canvas id="pvDaily"></canvas>
    </div>
    <!-- doughnut boxes ... -->
  </div>

  {# djlint:off #}
  <script>
    (function() {
      new Chart(document.getElementById("pvDaily"), {
        type: "bar",
        data: {
          labels: {{ chart_daily_labels|safe }},
          datasets: [{
            data: {{ chart_daily_data|safe }},
            backgroundColor: "#417690",
            maxBarThickness: 48,
          }],
        },
        options: {
          maintainAspectRatio: false,
          scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
        },
      });
      // ... doughnuts
    })();
  </script>
  {# djlint:on #}

  {{ block.super }}
{% endblock content %}
Enter fullscreen mode Exit fullscreen mode

Two things worth noting in the template:

  • {# djlint:off #} / {# djlint:on #} — djLint's formatter breaks Django template tags inside <script> blocks, splitting {{ variable|safe }} across multiple lines. Wrapping the script in these comments disables reformatting for that section.
  • maintainAspectRatio: false with an explicit CSS height on the canvas — without this, Chart.js stretches the canvas to fill its container and the bar chart looks enormous when there are only a few data points. Set height: 200px !important on the canvas element via CSS and maintainAspectRatio: false in the chart options.
  • maxBarThickness: 48 — caps bar width so early-stage data with just one or two bars doesn't produce a chart that looks like two rectangles filling the box.

The Result

The PageVisit changelist now shows:

  • A bar chart of daily visits for the selected period, with 7d / 30d / 90d toggle buttons in the top-right corner of the chart box
  • A doughnut chart of all-time visits broken down by page type (blog post / project / service)
  • A doughnut chart of all-time visits broken down by device (desktop / mobile / tablet / unknown)

Below the charts the standard Django admin table renders as normal — filters, search, pagination all work unchanged.


What It Didn't Require

No new Python packages. No admin theme swap. No JavaScript build step. The only external dependency is Chart.js loaded from jsDelivr, which is a single <script> tag.

Django's changelist_view override and template inheritance are enough. If you already have a working admin and a model worth charting, you can have this running in under an hour.

Top comments (0)