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="")
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)
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
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 %}
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: falsewith an explicit CSSheighton 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. Setheight: 200px !importanton the canvas element via CSS andmaintainAspectRatio: falsein 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)