Как превратить админку Django в легковесную панель инструментов
В статье описано как добавить сводную таблицу и диаграммы в Django Admin
Оригинальная статья: Haki Benita — How to Turn Django Admin Into a Lightweight Dashboard
Django Admin — это мощный инструмент для управления данными в вашем приложении. Тем не менее, он не был разработан для добавления сводных таблиц и диаграмм. К счастью, разработчики Django Admin упростили нам подобную настройку.
Вот что мы собираемся сделать к концу статьи:
Зачем нам это делать?
Существует множество инструментов, приложений и пакетов, которые могут создавать очень красивые панели. Я лично обнаружил, что если продукт не является реальной панелью мониторинга, в большинстве случаев все, что вам нужно, это простая сводная таблица и несколько диаграмм.
Второе, и не менее важное причина — отсутствие дополнительных зависимостей.
Если все, что вам нужно, это немного улучшить интерфейс администратора, то этот подход определенно стоит рассмотреть.
Начальная настройка
Мы собираемся использовать готовую модель Sale.
Чтобы использовать всю мощь Django Admin, мы собираемся создать нашу панель управления на базе ModelAdmin.
Для этого нам нужна модель:
# models.py class SaleSummary(Sale): class Meta: proxy = True verbose_name = 'Sale Summary' verbose_name_plural = 'Sales Summary'
Proxy-модель расширяет функциональность другой модели, не создавая фактическую таблицу в базе данных.
Теперь, когда у нас есть модель, мы можем создать ModelAdmin:
# admin.py from django.contrib import admin from .models import SaleSummary @admin.register(SaleSummary) class SaleSummaryAdmin(ModelAdmin): change_list_template = 'admin/sale_summary_change_list.html' date_hierarchy = 'created'
Поскольку мы используем стандартную ModelAdmin, мы можем использовать все его функции. В этом примере я добавил date_hierarchy для фильтрации продаж по дате создания. Мы собираемся использовать это позже для графика.
Чтобы страница выглядела как «обычная» страница администратора, мы расширим шаблон change_list Django и поместим наш контент в блок result_list:
<!-- sales/templates/admin/sale_summary_change_list.html --> {% extends "admin/change_list.html" %} {% block content_title %} <h1> Sales Summary </h1> {% endblock %} {% block result_list %} <!-- Our content goes here... --> {% endblock %} {% block pagination %}{% endblock %}
Вот как стала выглядит наша страница на данный момент:
Добавление сводной таблицы
Контекст, отправляемый в шаблон, заполняется ModelAdmin в функции changelist_view.
Чтобы отобразить таблицу в шаблоне, мы извлекаем данные в changelist_view и добавляем их в контекст:
# admin.py class SaleSummaryAdmin(ModelAdmin): # ... def changelist_view(self, request, extra_context=None): response = super().changelist_view( request, extra_context=extra_context, ) try: qs = response.context_data['cl'].queryset except (AttributeError, KeyError): return response metrics = { 'total': Count('id'), 'total_sales': Sum('price'), } response.context_data['summary'] = list( qs .values('sale__category__name') .annotate(**metrics) .order_by('-total_sales') ) return response
Давайте рассмотрим что мы сделали:
- Вызываем super, чтобы позволить Django делать свое дело (заполняем заголовки, breadcrumbs, наборы запросов, фильтров и т. д.).
- Извлекаем созданный для нас набор запросов из context. На этом этапе запрос фильтруется с помощью любых встроенных фильтров или иерархии дат, выбранных пользователем.
- Если мы не можем извлечь набор запросов из контекста, это, скорее всего, связано с неверными параметрами запроса. В таких случаях мы сразу возвращаем response.
- Суммируем общий объем продаж по категориям и возвращаем список (указание «метрики» станет понятным в следующем разделе).
Теперь, когда у нас есть данные в контексте, мы можем отобразить их в шаблоне:
<!-- sale_summary_change_list.html --> {% load humanize %} <!-- ... --> {% block result_list %} <div class="results"> <table> <thead> <tr> <th> <div class="text"> <a href="#">Category</a> </div> </th> <th> <div class="text"> <a href="#">Total</a> </div> </th> <th> <div class="text"> <a href="#">Total Sales</a> </div> </th> <th> <div class="text"> <a href="#"> <strong>% Of Total Sales</strong> </a> </div> </th> </tr> </thead> <tbody> {% for row in summary %} <tr class="{% cycle 'row1' 'row2' %}"> <td> {{ row.sale__category__name }} </td> <td> {{ row.total | intcomma }} </td> <td> {{ row.total_sales | default:0 | intcomma }}$ </td> <td> <strong> {{ row.total_sales | default:0 | percentof:summary_total.total_sales }} </strong> </td> </tr> {% endfor %} </tbody> </table> </div> <!-- ... --> {% endblock %}
Разметка важна. Чтобы получить нативный внешний вид Django, нам нужно отображать таблицы таким же образом, как Django их отображает.
Это то, что мы имеем на данном этапе:
Сейчас у нас есть сводная таблица без нижней строки. Мы можем использовать метрики и сделать немного вуду Django ORM, чтобы быстро вычислить итоговую сумму:
# admin.py class SaleSummaryAdmin(ModelAdmin): # ... def changelist_view(self, request, extra_context=None): # ... response.context_data['summary_total'] = dict( qs.aggregate(**metrics) ) return response
Это довольно крутой трюк …
Добавим нижнюю строку к таблице:
<!-- sale_summary_change_list.html --> <div class="results"> <table> <!-- ... --> <tr style="font-weight:bold; border-top:2px solid #DDDDDD;"> <td> Total </td> <td> {{ summary_total.total | intcomma }} </td> <td> {{ summary_total.total_sales | default:0 }}$ </td> <td> 100% </td> </tr> </table> </div>
Сводная таблица начинает обретать форму:
Добавление фильтров
Мы используем «обычную» модель администратора, поэтому фильтры уже встроены. Давайте добавим фильтр по устройству:
# admin.py class SaleSummaryAdmin(ModelAdmin): # ... list_filter = ( 'device', )
И результат:
Добавление диаграммы
Панель инструментов не является полной без графика, поэтому мы собираемся добавить гистограмму, чтобы показать продажи с течением времени.
Для построения нашей диаграммы мы будем использовать обычный HTML и несколько старых добрых CSS с flexbox. Данные для диаграммы будут представлять собой временной ряд процентов, которые будут использоваться в качестве высоты столбцов.
Возвращаясь к нашему changelist_view, мы добавляем следующее:
# admin.py from django.db.models.functions import Trunc from django.db.models import DateTimeField class SalesSummaryAdmin(ModelAdmin): # ... def changelist_view(self, request, extra_context=None): # ... summary_over_time = qs.annotate( period=Trunc( 'created', 'day', output_field=DateTimeField(), ), ).values('period') .annotate(total=Sum('price')) .order_by('period') summary_range = summary_over_time.aggregate( low=Min('total'), high=Max('total'), ) high = summary_range.get('high', 0) low = summary_range.get('low', 0) response.context_data['summary_over_time'] = [{ 'period': x['period'], 'total': x['total'] or 0, 'pct': \ ((x['total'] or 0) - low) / (high - low) * 100 if high > low else 0, } for x in summary_over_time] return response
Давайте добавим гистограмму в шаблон и немного ее стилизуем:
<!-- sale_summary_change_list.html --> <div class="results"> <!-- ... --> <h2> Sales over time </h2> <style> .bar-chart { display: flex; justify-content: space-around; height: 160px; padding-top: 60px; overflow: hidden; } .bar-chart .bar { flex: 100%; align-self: flex-end; margin-right: 2px; position: relative; background-color: #79aec8; } .bar-chart .bar:last-child { margin: 0; } .bar-chart .bar:hover { background-color: #417690; } .bar-chart .bar .bar-tooltip { position: relative; z-index: 999; } .bar-chart .bar .bar-tooltip { position: absolute; top: -60px; left: 50%; transform: translateX(-50%); text-align: center; font-weight: bold; opacity: 0; } .bar-chart .bar:hover .bar-tooltip { opacity: 1; } </style> <div class="results"> <div class="bar-chart"> {% for x in summary_over_time %} <div class="bar" style="height:{{x.pct}}%"> <div class="bar-tooltip"> {{x.total | default:0 | intcomma }}<br> {{x.period | date:"d/m/Y"}} </div> </div> {% endfor %} </div> </div> </div>
Для тех из вас, кто не знаком с flexbox, этот фрагмент CSS означает «рисовать снизу вверх, сместить влево и регулировать ширину по размеру».
Вот как это выглядит сейчас:
Это выглядит довольно хорошо, но … Каждый столбец на графике представляет день. Что произойдет, когда мы попытаемся показать данные за один день? Или несколько лет?
Такая диаграмма нечитаема и опасна. Извлечение такого количества данных может привести к зависанию сервера и/или созданию огромного HTML-файла.
Django Admin имеет иерархию дат — давайте посмотрим, сможем ли мы использовать это для корректировки периода столбцов на основе выбранной иерархии дат:
def get_next_in_date_hierarchy(request, date_hierarchy): if date_hierarchy + '__day' in request.GET: return 'hour' if date_hierarchy + '__month' in request.GET: return 'day' if date_hierarchy + '__year' in request.GET: return 'week' return 'month'
- Если пользователь отфильтровал один день, каждый столбец будет один час (максимум 24 столбца).
- Если пользователь выбрал месяц, каждый столбец будет один день (максимум 31 столбец).
- Если пользователь выбрал год, каждый столбец будет иметь одну неделю (максимум 52 столбца).
- Более того, и каждый столбец будет один месяц.
Теперь нам нужна всего одна небольшая корректировка для представления списка изменений:
# admin.py class SalesSummaryAdmin(ModelAdmin): # ... def changelist_view(self, request, extra_context=None): # ... period = get_next_in_date_hierarchy(request, self.date_hierarchy) response.context_data['period'] = period summary_over_time = qs.annotate( period=Trunc('created', period, output_field=DateTimeField()), ).values('period') .annotate(total=Sum('price')) .order_by('period') # ...
Аргумент period, переданный Trunc, теперь является параметром. В результате мы получаем вот это: