Графики и отчёты в Django Admin: Matplotlib, pandas и CSV
Как добавить в Django Admin отчёт с графиком: changelist_view и extra_context, Matplotlib в SVG без GUI, сводные таблицы pandas и экспорт в CSV.
Содержание · 12 разделов
- Что получится и из чего состоит отчёт
- Прокси-модель для отчёта
- Агрегаты через ORM
- График Matplotlib в SVG без GUI
- Сводная таблица pandas
- changelist_view: данные с учётом фильтров
- Шаблон отчёта
- Экспорт в CSV через admin action
- Производительность
- Частые ошибки
- Отчёт показывает все данные, а не отфильтрованные
- KeyError или AttributeError: context_data
- Месяцев в графике столько же, сколько заказов
- Вместо графика на странице текст с тегами svg
- Ошибки Tkinter или «main thread is not in main loop»
- Excel открывает CSV одной колонкой или с «кракозябрами»
- Частые вопросы
- Как выгрузить отчёт в Excel, а не в CSV?
- Как показать только отчёт, без таблицы записей?
- Как построить график по дням или неделям?
- Как дать доступ к отчёту только руководителям?
- Почему отчёт не меняется после новых заказов?
- Итог
Django Admin создан для работы с записями, но простой отчёт в нём собрать несложно: график выручки над списком заказов, сводную таблицу по статусам и кнопку выгрузки в CSV. Отдельная BI-система для этого не нужна, а отчёт подчиняется фильтрам, поиску и правам админки.
Коротко: переопределите changelist_view в ModelAdmin и возьмите из ответа отфильтрованный QuerySet (response.context_data["cl"].queryset). Посчитайте агрегаты в базе, нарисуйте график через matplotlib.figure.Figure в SVG и добавьте результат в контекст. Шаблон change_list.html выведет его над таблицей. Примеры проверены на Django 5.2 LTS и 6.x, Matplotlib 3.11 и pandas 2.2 / 3.0.
Что получится и из чего состоит отчёт
| Часть | Где живёт | Задача |
|---|---|---|
| Прокси-модель | shop/models.py | отдельный пункт «Отчёт по продажам» в меню |
| Расчёты | shop/reports.py | агрегаты ORM, график, сводная таблица |
ModelAdmin | shop/admin.py | changelist_view, кеш, действие «в CSV» |
| Шаблон | templates/admin/shop/salesreport/change_list.html | вывод графика над списком |
Пакеты ставятся в виртуальное окружение проекта:
python -m pip install matplotlib pandas
Прокси-модель для отчёта
Отчёт удобно вынести в отдельный пункт меню, не трогая обычный список заказов. Для этого подойдёт прокси-модель: она работает с той же таблицей, но регистрируется в админке отдельно и получает собственные права (view_salesreport и другие).
# shop/models.py
from django.db import models
from django.utils import timezone
class Order(models.Model):
class Status(models.TextChoices):
NEW = "new", "Новый"
PAID = "paid", "Оплачен"
CANCELED = "canceled", "Отменён"
customer = models.CharField("Покупатель", max_length=200)
status = models.CharField(
"Статус", max_length=20, choices=Status.choices, default=Status.NEW
)
amount = models.DecimalField("Сумма", max_digits=10, decimal_places=2)
created = models.DateTimeField("Создан", default=timezone.now, db_index=True)
class Meta:
ordering = ["-created"]
class SalesReport(Order):
class Meta:
proxy = True
verbose_name = "отчёт по продажам"
verbose_name_plural = "отчёт по продажам"
Таблицу прокси-модель не создаёт, но миграция нужна: в ней Django создаёт права доступа. Выполните python manage.py makemigrations и python manage.py migrate.
Агрегаты через ORM
Считайте в базе данных, а не в Python: annotate() с TruncMonth вернёт по одной строке на месяц, сколько бы заказов ни было в таблице.
# shop/reports.py
from django.db.models import Count, Sum
from django.db.models.functions import TruncMonth
from .models import Order
def monthly_revenue(queryset):
return list(
queryset.filter(status=Order.Status.PAID)
.annotate(month=TruncMonth("created"))
.values("month")
.annotate(revenue=Sum("amount"), orders=Count("id"))
.order_by("month")
)
Важно: вызов
.order_by("month")обязателен. QuerySet из списка админки уже отсортирован (по-createdи-pk), а поля сортировки попадают вGROUP BY. Без сброса сортировки группировка идёт по каждой записи. На тестовых данных вместо 5 месячных строк получилось 146.
Подробнее о values(), annotate() и функциях дат — в шпаргалке по Django ORM.
График Matplotlib в SVG без GUI
На сервере нет дисплея, и графический бэкенд Matplotlib там не нужен. В веб-приложении создавайте matplotlib.figure.Figure напрямую, без pyplot. У такой фигуры нет глобального состояния, её не нужно закрывать через plt.close(), и при сохранении она не обращается к GUI-бэкенду.
# shop/reports.py (продолжение)
from io import StringIO
from matplotlib.figure import Figure
def revenue_chart_svg(rows):
fig = Figure(figsize=(8, 3), layout="constrained")
ax = fig.subplots()
ax.bar(
[row["month"].strftime("%m.%Y") for row in rows],
[float(row["revenue"]) for row in rows],
color="#417690",
)
ax.set_ylabel("Выручка, ₽")
ax.grid(axis="y", alpha=0.3)
buffer = StringIO()
fig.savefig(buffer, format="svg")
return buffer.getvalue()
SVG масштабируется без потери качества и встраивается прямо в HTML. Кириллица отображается шрифтом DejaVu Sans, который входит в Matplotlib. По умолчанию текст в SVG сохраняется контурами, поэтому от шрифтов на компьютере пользователя он не зависит. На тестовых данных такой график занимал около 20 КБ.
Если нужен PNG (например, чтобы вставить картинку в письмо), сохраните фигуру в BytesIO и встройте как data URI:
import base64
from io import BytesIO
def chart_png_data_uri(fig):
buffer = BytesIO()
fig.savefig(buffer, format="png", dpi=100)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
Если в проекте уже есть код на pyplot, переключите бэкенд на неинтерактивный до импорта pyplot: matplotlib.use("Agg") или переменная окружения MPLBACKEND=Agg. После каждого графика вызывайте plt.close(fig), иначе фигуры копятся в памяти процесса.
Сводная таблица pandas
pandas удобен там, где нужна перекрёстная таблица «месяц × статус». Сначала база группирует данные, потом в pandas попадают уже готовые суммы, а не все заказы:
# shop/reports.py (продолжение)
import pandas as pd
def status_pivot_html(queryset):
rows = (
queryset.annotate(month=TruncMonth("created"))
.values("month", "status")
.annotate(total=Sum("amount"))
.order_by()
)
df = pd.DataFrame.from_records(rows)
if df.empty:
return ""
df["total"] = df["total"].astype(float)
df["month"] = df["month"].dt.strftime("%Y-%m")
df["status"] = df["status"].map(dict(Order.Status.choices))
table = df.pivot_table(
index="month", columns="status", values="total",
aggfunc="sum", fill_value=0,
)
table["Итого"] = table.sum(axis=1)
table = table.rename_axis(index="Месяц", columns=None).reset_index()
return table.to_html(
index=False, classes="report-table", border=0,
float_format="{:,.0f}".format,
)
Что здесь важно:
DecimalFieldприходит из базы какDecimal. Приведение кfloatнужно для числовых операций pandas.to_html()экранирует значения ячеек, поэтому названия из базы не превратятся в HTML-разметку..order_by()без аргументов сбрасывает сортировку списка по той же причине, что и в разделе об агрегатах.
Основы работы с таблицами описаны в статье про библиотеку pandas.
changelist_view: данные с учётом фильтров
Теперь соединим всё в ModelAdmin. Сначала вызываем родительский changelist_view: он применяет фильтры, поиск и date_hierarchy и возвращает TemplateResponse, который ещё не отрендерен. Затем берём из его контекста объект ChangeList и добавляем данные отчёта.
# shop/admin.py
import hashlib
from django.contrib import admin
from django.core.cache import cache
from .models import SalesReport
from .reports import monthly_revenue, revenue_chart_svg, status_pivot_html
@admin.register(SalesReport)
class SalesReportAdmin(admin.ModelAdmin):
list_display = ["id", "customer", "status", "amount", "created"]
list_filter = ["status", "created"]
date_hierarchy = "created"
show_full_result_count = False
def has_add_permission(self, request):
return False
def changelist_view(self, request, extra_context=None):
response = super().changelist_view(request, extra_context)
try:
queryset = response.context_data["cl"].queryset
except (AttributeError, KeyError):
# редирект при ошибке в фильтре или ответ admin action
return response
cache_key = "sales-report:" + hashlib.md5(
request.GET.urlencode().encode()
).hexdigest()
report = cache.get_or_set(
cache_key, lambda: self.build_report(queryset), timeout=600
)
response.context_data.update(report)
return response
def build_report(self, queryset):
rows = monthly_revenue(queryset)
return {
"revenue_rows": rows,
"chart_svg": revenue_chart_svg(rows) if rows else "",
"pivot_html": status_pivot_html(queryset),
}
Почему не extra_context? Его можно передать в super().changelist_view(), но тогда отчёт придётся считать по всей таблице: отфильтрованный QuerySet появляется только внутри родительского метода. Если фильтры не нужны, достаточно и такого варианта: extra_context = {**(extra_context or {}), "chart_svg": ...}.
Блок try/except обязателен. При неверном параметре фильтра админка отвечает редиректом на ?e=1, а при выполнении действия — его собственным ответом. У таких ответов нет context_data.
Шаблон отчёта
Админка сама ищет шаблон admin/shop/salesreport/change_list.html (метка приложения и имя модели в нижнем регистре), поэтому change_list_template указывать не нужно:
{% extends "admin/change_list.html" %}
{% load static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" href="{% static 'shop/report.css' %}">
{% endblock %}
{% block content %}
{% if revenue_rows %}
<div class="module sales-report">
<h2>Выручка по месяцам (с учётом фильтров)</h2>
{{ chart_svg|safe }}
{{ pivot_html|safe }}
</div>
{% endif %}
{{ block.super }}
{% endblock %}
Фильтр |safe здесь допустим: SVG и HTML-таблицу генерирует ваш код, а текст внутри них Matplotlib и pandas экранируют. Не применяйте |safe к строкам, которые пришли от пользователя напрямую. {{ block.super }} сохраняет стандартный список с фильтрами и действиями. Стили страницы списка подключаются в extrastyle, поэтому {{ block.super }} нужен и там.
Если вы предпочитаете интерактивные графики в браузере, передайте ряды через json_script и нарисуйте их Chart.js. Этот подход подробно разобран в статье о дашборде Django Admin.
Экспорт в CSV через admin action
Действие получает QuerySet выбранных строк. Если пользователь нажал «Выбрать все», в QuerySet попадут все записи с учётом текущих фильтров. Excel в русской локали ожидает разделитель ;, а UTF-8 распознаёт по BOM в начале файла:
# shop/admin.py — добавьте импорты в начало файла
import csv
from django.http import HttpResponse
from django.utils import timezone
from .models import Order
@admin.register(SalesReport)
class SalesReportAdmin(admin.ModelAdmin):
# ... атрибуты и changelist_view из предыдущего раздела
actions = ["export_csv"]
@admin.action(description="Выгрузить в CSV", permissions=["view"])
def export_csv(self, request, queryset):
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = 'attachment; filename="orders.csv"'
response.write("") # BOM, чтобы Excel распознал UTF-8
statuses = dict(Order.Status.choices)
writer = csv.writer(response, delimiter=";")
writer.writerow(["ID", "Покупатель", "Статус", "Сумма", "Создан"])
rows = queryset.values_list("id", "customer", "status", "amount", "created")
for pk, customer, status, amount, created in rows.iterator(chunk_size=2000):
writer.writerow([
pk, customer, statuses.get(status, status), amount,
timezone.localtime(created).strftime("%d.%m.%Y %H:%M"),
])
return response
Параметр permissions=["view"] показывает действие всем, у кого есть право просмотра отчёта. Без него встроенное действие удаления требует права delete, а ваше будет доступно всем, кто видит список. values_list() и iterator() не создают объекты моделей и не держат всю выборку в памяти.
Для выгрузок на сотни тысяч строк используйте StreamingHttpResponse: файл отдаётся по частям, и запрос не упирается в таймаут прокси. Готовый шаблон с классом Echo есть в документации Django. Ещё тяжелее? Формируйте файл в фоновой задаче, этот приём описан в статье об оптимизации массовых действий.
Производительность
Отчёт пересчитывается при каждом открытии списка, поэтому стоит заранее подумать о нагрузке:
- Кеш по параметрам запроса. В примере ключ строится из
request.GET, поэтому каждая комбинация фильтров кешируется отдельно. Еслиget_queryset()фильтрует данные по пользователю, добавьтеrequest.user.pkв ключ, иначе один менеджер увидит отчёт другого. - Ограничение периода.
date_hierarchyи фильтр по дате помогают не строить график по всей истории. Индекс на поле даты (db_index=True) ускоряет такие запросы. show_full_result_count = Falseубирает лишнийCOUNT(*)по всей таблице при активных фильтрах.- Matplotlib не бесплатен. Отрисовка графика занимает десятки миллисекунд даже на маленьких данных. Ещё одна причина кешировать готовый SVG, а не только цифры.
- Без N+1. Если в
list_displayесть поля связанных моделей, включитеlist_select_related(подробности — в статье о проблеме N+1).
Когда отчётов становится больше трёх-четырёх и пользователям нужны свои срезы, подключите отдельный BI-инструмент (Metabase, Redash, Superset) к реплике базы, а за админкой оставьте работу с записями.
Частые ошибки
Отчёт показывает все данные, а не отфильтрованные
Вы считаете по self.model.objects или self.get_queryset(request). Фильтры, поиск и date_hierarchy применяются только в ChangeList, поэтому берите response.context_data["cl"].queryset.
KeyError или AttributeError: context_data
Родительский метод вернул редирект или ответ действия. Оберните доступ к context_data в try/except, как в примере.
Месяцев в графике столько же, сколько заказов
Сортировка списка попала в GROUP BY. Добавьте .order_by("month") или .order_by() после annotate().
Вместо графика на странице текст с тегами svg
Шаблон экранировал строку. Выведите её через {{ chart_svg|safe }}.
Ошибки Tkinter или «main thread is not in main loop»
Используется pyplot с GUI-бэкендом в потоке веб-сервера. Перейдите на matplotlib.figure.Figure или задайте бэкенд Agg до импорта pyplot.
Excel открывает CSV одной колонкой или с «кракозябрами»
Нет BOM или разделитель — запятая. Запишите "" первой строкой и используйте delimiter=";".
Частые вопросы
Как выгрузить отчёт в Excel, а не в CSV?
Соберите DataFrame из values() и сохраните его в память методом to_excel() — понадобится пакет openpyxl (python -m pip install openpyxl). Excel не хранит часовые пояса, поэтому дату переведите в местное время и уберите пояс:
from io import BytesIO
import pandas as pd
from django.http import HttpResponse
@admin.action(description="Выгрузить в Excel", permissions=["view"])
def export_xlsx(self, request, queryset):
df = pd.DataFrame.from_records(
queryset.values("id", "customer", "status", "amount", "created")
)
if not df.empty:
df["amount"] = df["amount"].astype(float)
df["created"] = df["created"].dt.tz_convert("Europe/Moscow").dt.tz_localize(None)
buffer = BytesIO()
df.to_excel(buffer, index=False, sheet_name="Заказы")
response = HttpResponse(
buffer.getvalue(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response["Content-Disposition"] = 'attachment; filename="orders.xlsx"'
return response
Метод добавьте в SalesReportAdmin и впишите его имя в actions. Для сотен тысяч строк такой файл лучше собирать в фоновой задаче.
Как показать только отчёт, без таблицы записей?
Переопределите в том же шаблоне блок result_list пустым: фильтры, поиск и date_hierarchy останутся, а таблица и действия исчезнут.
{% block result_list %}{% endblock %}
Как построить график по дням или неделям?
Замените TruncMonth на TruncDay или TruncWeek из django.db.models.functions и поменяйте формат подписи, например на "%d.%m". Длинный период по дням лучше ограничить фильтром по дате, иначе столбцы сольются.
Как дать доступ к отчёту только руководителям?
У прокси-модели свои права. Создайте группу, выдайте ей shop.view_salesreport и добавьте в неё нужных сотрудников. Право на обычный список заказов shop.view_order для этого не требуется.
Почему отчёт не меняется после новых заказов?
Его держит кеш: в примере — 10 минут на каждую комбинацию фильтров. Уменьшите timeout или добавьте в ключ номер версии: храните его в кеше и увеличивайте через cache.incr() в обработчике сигнала post_save модели Order — так устареют сразу все варианты отчёта. Если отчёт строится быстро, кеш можно не использовать вовсе.
Итог
Отчёт в Django Admin складывается из прокси-модели, переопределённого changelist_view с отфильтрованным QuerySet из cl.queryset, агрегатов ORM, графика Figure в SVG и шаблона change_list.html с {{ block.super }}. Выгрузка в CSV — это admin action с BOM и iterator(), а кеш по параметрам запроса снимает нагрузку с базы.
Базовая настройка ModelAdmin, фильтров и действий описана в полном руководстве по Django Admin. Как изменить оформление страниц админки, рассказывает статья о переопределении шаблонов и стилей.
- #Django Admin
- #Отчёты
- #Matplotlib
- #Pandas
- #CSV
- #Шаблоны Admin