Django-Admin.ru

Графики и отчёты в Django Admin: Matplotlib, pandas и CSV

Как добавить в Django Admin отчёт с графиком: changelist_view и extra_context, Matplotlib в SVG без GUI, сводные таблицы pandas и экспорт в CSV.

Редакция Django-Admin.ru
· обновлено · 11 мин чтения

Содержание · 12 разделов
  1. Что получится и из чего состоит отчёт
  2. Прокси-модель для отчёта
  3. Агрегаты через ORM
  4. График Matplotlib в SVG без GUI
  5. Сводная таблица pandas
  6. changelist_view: данные с учётом фильтров
  7. Шаблон отчёта
  8. Экспорт в CSV через admin action
  9. Производительность
  10. Частые ошибки
  11. Отчёт показывает все данные, а не отфильтрованные
  12. KeyError или AttributeError: context_data
  13. Месяцев в графике столько же, сколько заказов
  14. Вместо графика на странице текст с тегами svg
  15. Ошибки Tkinter или «main thread is not in main loop»
  16. Excel открывает CSV одной колонкой или с «кракозябрами»
  17. Частые вопросы
  18. Как выгрузить отчёт в Excel, а не в CSV?
  19. Как показать только отчёт, без таблицы записей?
  20. Как построить график по дням или неделям?
  21. Как дать доступ к отчёту только руководителям?
  22. Почему отчёт не меняется после новых заказов?
  23. Итог

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, график, сводная таблица
ModelAdminshop/admin.pychangelist_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
↑ ↓ выборEnter открытьEsc закрыть