📊Django, Pandas и Chart.js для быстрой панели инструментов
Попробуем разобраться, как можно использовать Django, Pandas и Chart.js для быстрого отображения данных в виде различных графиков и диаграмм.
Перевод публикуется с сокращениями, автор оригинальной статьи Shane Gary.
Эту связку полезно применять для быстрой визуализации одной таблицы с несколькими различными графиками, а также для создания надежного сайта на Django и Chart.js.
Почему Pandas? Все задачи можно выполнить изнутри Django непосредственно из БД, т. к. правильные запросы к базе всегда будут выгоднее для продакшена.
Почему Django? Это отличный выбор для проекта, где необходимо экспортировать данные в одну таблицу. Использование Flask будет более выигрышным вариантом, если не нужна аутентификация и доступ к данным из существующей базы.
Почему Chart.js? Если вы хотите развернуть кучу различных интерактивных диаграмм, изменив несколько переменных – Chart.js сделает это быстрее других.
Исходные тексты и простой набор данных из туториала вы найдете по ссылкам.
Настройка Django
Все действия в этом проекте будут происходить на сервере с Debian.
pip install django pandas django-admin startproject django_charts cd django_charts python manage.py migrate python manage.py createsuperuser python manage.py startapp data cd data mkdir templates cd .. python manage.py runserver
Дополнительно можете установить себе palettable – цветовую палитру для Python, однако код будет работать и без нее.
pip install palettable
На основе набора данных мы создадим следующую модель, но она должна быть изменена под ваши нужды.
from django.db import models class Purchase(models.Model): city = models.CharField(max_length=50) customer_type = models.CharField(max_length=50) gender = models.CharField(max_length=50) unit_price = models.FloatField() quantity = models.IntegerField() product_line = models.CharField(max_length=50) tax = models.FloatField() total = models.FloatField() date = models.DateField() time = models.TimeField() payment = models.CharField(max_length=50) cogs = models.FloatField() profit = models.FloatField() rating = models.FloatField()
Обязательно обновите БД после ее создания:
python manage.py makemigrations python manage.py migrate
С помощью Pandas и Django сделайте загрузку csv в базу данных для Kaggle:
import pandas as pd from .models import Purchase # dataset from https://www.kaggle.com/aungpyaeap/supermarket-sales # headers changed and invoice number col removed def csv_to_db(): df = pd.read_csv('supermarket_sales.csv') # use pandas to read the csv records = df.to_records() # convert to records # loop through and create a purchase object using django for record in records: purchase = Purchase( city=record[3], customer_type=record[4], gender=record[5], product_line=record[6], unit_price=record[7], quantity=record[8], tax=record[9], total=record[10], date=datetime.strptime(record[11], '%m/%d/%Y').date(), time=record[12], payment=record[13], cogs=record[14], profit=record[16], rating=record[17], ) purchase.save()
Импортируйте себе всю эту штуку:
from django.views.generic import TemplateView from .methods import csv_to_db class Dashboard(TemplateView): template_name = 'dashboard.html' def get_context_data(self, **kwargs): # get the data from the default method context = super().get_context_data(**kwargs) csv_to_db()
Затем создайте пустой файл base/dashboard.html и data/urls.py:
from django.urls import path from data import views urlpatterns = [ path('', views.Dashboard.as_view(), name='dashboard') ]
Отредактируйте django_charts/urls.py, чтобы добавить URL для данных:
from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('data/', include('data.urls')), ]
Обновите список django_charts/settings.py, чтобы включить data в INSTALLED_APPS:
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'data', ]
Убедимся, что можем видеть покупку в админке, добавив это в data/admin.py:
from django.contrib import admin from .models import Purchase admin.site.register(Purchase)
Теперь вы можете перейти на страницу дашборда (127.0.0.1:8000/data/), а потом проверить в админке (127.0.0.1:8000/admin/data/purchase/), видны ли все записи. Если вы используете тот же набор данных, их должно быть около 1000.
Настройка HTML
Есть базовый файл, который мы будем расширять:
{% load static %} <!doctype html> <html lang="en"> <head> <title> {% block title %}{% endblock %} </title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <link rel="shortcut icon" type="image/x-icon" href="{% static 'img/favicon.png' %}"> <!-- Bootstrap CSS --> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous"> </head> <body> {% block page_content %}{% endblock %} <!-- jQuery first, then Popper.js, then Bootstrap JS --> <script src="http://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"> </script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"> </script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"> </script> <!-- Chart.JS --> <script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.3"></script> <script> $(document).ready(function(){ {% block js_scripts %}{% endblock %} }) </script> </body> </html>
Немного модернизируем его:
{% block title %}{% endblock %} {% block custom_css %}{% endblock %} {% block page_content %}{% endblock %} {% block js_scripts %}{% endblock %}
Это даст возможность добавлять блоки с любой страницы. Обратите внимание, что здесь включен bootstrap CDN, чтобы использовать popper.js (это опционально). Можно выполнить проект совсем без bootstrap, но придется отредактировать dashboard.html.
Все написанные сценарии обернуты в $(document).ready
, чтобы не
происходило никаких манипуляций до тех пор, пока страница не будет готова.
Далее рассмотрим страницу с графиками – dashboard.html:
{% extends 'base.html' %} {% block page_content %} <div class="container"> <div class="row mb-4 mt-4"> <div class="col" <div class="card-deck"> {% for chart in charts %} <div class="card"> <div class="card-body"> <div class="chart-container" style="height:150; width:150"> {{ chart.html|safe }} </div> </div> </div> {% if forloop.counter|divisibleby:2 %} <div class="w-100 d-none d-sm-block d-md-none mb-4"><!-- wrap every 2 on sm--></div> {% endif %} {% if forloop.counter|divisibleby:3 %} <div class="w-100 d-none d-md-block d-lg-none mb-4"><!-- wrap every 3 on md--></div> {% endif %} {% if forloop.counter|divisibleby:4 %} <div class="w-100 d-none d-lg-block d-xl-none mb-4"><!-- wrap every 4 on lg--></div> {% endif %} {% if forloop.counter|divisibleby:5 %} <div class="w-100 d-none d-xl-block mb-4"><!-- wrap every 5 on xl--></div> {% endif %} {% endfor %} </div> </div> </div> </div> {% endblock %} {% block js_scripts %} {% for chart in charts %} {{ chart.js|safe }} {% endfor %} {% endblock %}
Здесь создается контейнер, строка и колонка, внутри которой лежит «колода карт», создающая карты одинакового размера.
Колода карт будет пытаться втиснуть все в одну строку. Счетчики forloop.counters используются для определения размера экрана и соответствующего обертывания колоды карт, однако на практике вы обнаружите, что Chart.js зачастую игнорирует настройки размера холста.
views.py
Чтобы лучше понять происходящее, посмотрим на views.py:
import pandas as pd import numpy as np from django.views.generic import TemplateView from .methods import csv_to_db from .models import Purchase from .charts import objects_to_df, Chart PALETTE = ['#465b65', '#184c9c', '#d33035', '#ffc107', '#28a745', '#6f7f8c', '#6610f2', '#6e9fa5', '#fd7e14', '#e83e8c', '#17a2b8', '#6f42c1' ] class Dashboard(TemplateView): template_name = 'dashboard.html' def get_context_data(self, **kwargs): # получение данные из метода по умолчанию context = super().get_context_data(**kwargs) # поля, которые мы будем использовать # df_fields = ['city', 'customer_type', 'gender', 'unit_price', 'quantity', # 'product_line', 'tax', 'total' , 'date', 'time', 'payment', # 'cogs', 'profit', 'rating'] # поля для исключения # df_exclude = ['id', 'cogs'] # создание фрейма данных с записями. chart.js не очень хорошо справляется # с датами во всех ситуациях, поэтому наш метод преобразует их в строки # и нужно будет определить столбцы дат и нужный формат. df = objects_to_df(Purchase, date_cols=['%Y-%m', 'date']) # создание контекста charts для хранения всех графиков context['charts'] = [] ### каждая диаграмма добавляется одинаково поэтому опишем первую # создадим объект диаграммы с уникальным chart_id и цветовой палитрой # если не указан chart_id или цветовая палитра, они будут генерироваться рандомно # тип диаграмм должен быть идентифицирован здесь и отличаться от типа chartjs city_payment_radar = Chart('radar', chart_id='city_payment_radar', palette=PALETTE) # создадим сводную таблицу pandas на основе полей и агрегации, которые мы хотим # стеки используются либо для группировки, либо для укладки определенного столбца city_payment_radar.from_df(df, values='total', stacks=['payment'], labels=['city']) # добавим контекст context['charts'].append(city_payment_radar.get_presentation()) exp_polar = Chart('polarArea', chart_id='polar01', palette=PALETTE) exp_polar.from_df(df, values='total', labels=['payment']) context['charts'].append(exp_polar.get_presentation()) exp_doughnut = Chart('doughnut', chart_id='doughnut01', palette=PALETTE) exp_doughnut.from_df(df, values='total', labels=['city']) context['charts'].append(exp_doughnut.get_presentation()) exp_bar = Chart('bar', chart_id='bar01', palette=PALETTE) exp_bar.from_df(df, values='total', labels=['city']) context['charts'].append(exp_bar.get_presentation()) city_payment = Chart('groupedBar', chart_id='city_payment', palette=PALETTE) city_payment.from_df(df, values='total', stacks=['payment'], labels=['date']) context['charts'].append(city_payment.get_presentation()) city_payment_h = Chart('horizontalBar', chart_id='city_payment_h', palette=PALETTE) city_payment_h.from_df(df, values='total', stacks=['payment'], labels=['city']) context['charts'].append(city_payment_h.get_presentation()) city_gender_h = Chart('stackedHorizontalBar', chart_id='city_gender_h', palette=PALETTE) city_gender_h.from_df(df, values='total', stacks=['gender'], labels=['city']) context['charts'].append(city_gender_h.get_presentation()) city_gender = Chart('stackedBar', chart_id='city_gender', palette=PALETTE) city_gender.from_df(df, values='total', stacks=['gender'], labels=['city']) context['charts'].append(city_gender.get_presentation()) return context
Будем использовать TemplateView. Это очень простое view, к которому можно что-то добавить. Единственный метод, который необходимо расширить – get_context_data, использующийся в Django для получения данных.
Мы вытаскиваем нужные объекты и создаем фрейм данных. Известно, что Chart.js не очень хорошо работает с датами – конвертируем их в строки после создания фрейма. Затем добавляем каждый график в контекст Chart. Это позволяет перебирать графики в коде HTML, т. к. каждая диаграмма представляет собой словарь, содержащий гипертекст и js-запись.
Charts.py
И наконец файл data/charts.py. Весь код легко переносится в другой проект, и вы можете просто поместить его в свой view. Пробежимся по некоторым функциям, а затем перейдем к классу Chart.
def objects_to_df(model, fields=None, exclude=None, date_cols=None, **kwargs): """ Возвращает фрейм данных pandas, содержащий записи в модели ``fields`` это необязательный список имен полей. Если это предусмотрено, вернется только имя. ``exclude`` это необязательный список имен полей. Если это предусмотрено, именованные элементы исключатся из возвращаемого dict ``date_cols`` chart.js в настоящее время он не очень хорошо обрабатывает даты, поэтому эти столбцы должны быть преобразованы в строку. ``kwargs`` можно включить, чтобы ограничить модельный запрос конкретными записями """ if not fields: fields = [field.name for field in model._meta.get_fields()] if exclude: fields = [field for field in fields if field not in exclude] records = model.objects.filter(**kwargs).values_list(*fields) df = pd.DataFrame(list(records), columns=fields) if date_cols: strftime = date_cols.pop(0) for date_col in date_cols: df[date_col] = df[date_col].apply(lambda x: x.strftime(strftime)) return df
Эта функция принимает модель Django и возвращает все записи. Можно включить некоторые фильтры после всех аргументов имени. Например:
objects_to_df(Purchase, fields=None, exclude=['id'], date_cols=['%Y-%m', 'date'], city='Mandalay')
Результат будет ограничен городом Мандалай. Поля include и exclude работают аналогично Django. Если вы ничего не включаете, будут выведены все поля из модели. Важно отметить, что exclude обрабатываются после include. Таким образом, если включить и исключить столбец, он не будет отображаться.
def get_options(): """ Дефолтное значение для всех графиков """ return {}
Этот код устанавливает параметры по умолчанию для всех графиков.
def generate_chart_id(): """ Вернет 8 рандомных сгенерированных символов ascii """ return ''.join(random.choice(string.ascii_letters) for i in range(8))
Если вы не хотите утруждать себя маркировкой каждого из них, данная функция устанавливает случайный ID, используемый JS для изменения элемента HTML. Этот идентификатор также используется для именования функции JS и переменной, чтобы облегчить отладку.
from palettable.lightbartlein.diverging import BlueDarkRed12_6 as palette # example import def get_colors(): """ Цвета из palette.colors или случайным образом сгенерированный список цветов. Отлично работает с модулем palettable но не является обязательным и будет вызывать get_random_colors если palette.colors не задана """ try: return palette.hex_colors except: return get_random_colors(6)
get_colors применяется для установки начальных цветов. Если ее передать в палитру, она будет использоваться в качестве основной. Если ничего не передавать и не импортировать из palletable, то цвета будут генерироваться случайным образом.
def get_random_colors(num, colors=[]): while len(colors) < num: color = "#{:06x}".format(random.randint(0, 0xFFFFFF)) if color not in colors: colors.append(color) return colors
get_random_colors используется, если цвета не были переданы или когда их больше, чем значений. Все начинается с переданных цветов, а в процессе они добавляются случайным образом.
По функциям все. Ниже приведен класс Chart.
@dataclass class Chart: """ Класс в помощь к chart.js. ``datasets`` собственно данные. Содержит данные и варианты их получения. ``labels`` метки для данных. ``chart_id`` уникальный ID диаграммы. Будет сгенерирован рандомно если таковой не предусмотрен. Это должна быть допустимая JS-переменная. Не используйте '-' ``palette`` список цветов. Будет сгенерирован, если ни один не указан. """ chart_type: str datasets: List = field(default_factory=list) labels: List = field(default_factory=list) chart_id: str = field(default_factory=generate_chart_id) palette: List = field(default_factory=get_colors) options: dict = field(default_factory=get_options)
Здесь использовался dataclass – это относительно недавнее дополнение Python и для быстрых классов оно будет хорошим выбором. Установка начальных значений помогает гарантировать, что вы не используете изменяемый объект для экземпляра.
def from_lists(self, values, labels, stacks): """ Функция построения графиков из списка ``values`` список датасетов, содержащий одно значение. ``labels`` метки значений. ``stacks`` метки для каждого набора данных в списке значений. """ self.datasets = [] # убеждаемся, что у нас правильное количество цветов if len(self.palette) < len(values): get_random_colors(num=len(values), colors=self.palette) # создаем датасет for i in range(len(stacks)): self.datasets.append( { 'label': stacks[i], 'backgroundColor': self.palette[i], 'data': values[i], } ) if len(values) == 1: self.datasets[0]['backgroundColor'] = self.palette self.labels = labels
from_dataframe помогает использовать несколько строк кода для манипулирования практически любым фреймом данных, чтобы передавать все непосредственно в from_lists для Chart.js. Мы применяем метод pivot_table и Pandas, чтобы создать pivot_table на основе входных данных. Она может выгружать списки, которые нужны для диаграммы.
def from_df(self, df, values, labels, stacks=None, aggfunc=np.sum, round_values=0, fill_value=0): """ Функция построения графиков из датафрейма. ``df`` используемый датафрейм. ``values`` имя колонки со значениями. ``stacks`` имя колонки со stack-ами. ``labels`` имя колонки с метками. ``aggfunc`` функция, агрегирующая значения. ``round_values`` десятичный знак для округления. ``fill_value`` используется если значение пустое. """ pivot = pd.pivot_table( df, values=values, index=stacks, columns=labels, aggfunc=aggfunc, fill_value=0 ) pivot = pivot.round(round_values) values = pivot.values.tolist() labels = pivot.columns.tolist() stacks = pivot.index.tolist() self.from_lists(values, labels, stacks)
Заключение
Чтобы внедрить код в своем проекте, скопируйте chart.py и используйте его, как views.py в нашем. Обязательно убедитесь, что базовый файл HTML импортирует chart.js cdn. Bootstrap – по желанию. Удачи в обучении и экспериментах!
Весь код из статьи:
@dataclass class Chart: chart_type: str datasets: List = field(default_factory=list) labels: List = field(default_factory=list) chart_id: str = field(default_factory=generate_chart_id) palette: List = field(default_factory=get_colors) options: dict = field(default_factory=get_options) def from_lists(self, values, labels, stacks): self.datasets = [] if len(self.palette) < len(values): get_random_colors(num=len(values), colors=self.palette) for i in range(len(stacks)): self.datasets.append( { 'label': stacks[i], 'backgroundColor': self.palette[i], 'data': values[i], } ) if len(values) == 1: self.datasets[0]['backgroundColor'] = self.palette self.labels = labels def from_df(self, df, values, labels, stacks=None, aggfunc=np.sum, round_values=0, fill_value=0): pivot = pd.pivot_table( df, values=values, index=stacks, columns=labels, aggfunc=aggfunc, fill_value=0 ) pivot = pivot.round(round_values) values = pivot.values.tolist() labels = pivot.columns.tolist() stacks = pivot.index.tolist() self.from_lists(values, labels, stacks) def get_elements(self): elements = { 'data': { 'labels': self.labels, 'datasets': self.datasets }, 'options': self.options } if self.chart_type == 'stackedBar': elements['type'] = 'bar' self.options['scales'] = { 'xAxes': [ {'stacked': 'true'} ], 'yAxes': [ {'stacked': 'true'} ] } if self.chart_type == 'bar': elements['type'] = 'bar' self.options['scales'] = { 'xAxes': [ { 'ticks': { 'beginAtZero': 'true' } } ], 'yAxes': [ { 'ticks': { 'beginAtZero': 'true' } } ] } if self.chart_type == 'groupedBar': elements['type'] = 'bar' self.options['scales'] = { 'xAxes': [ { 'ticks': { 'beginAtZero': 'true' } } ], 'yAxes': [ { 'ticks': { 'beginAtZero': 'true' } } ] } if self.chart_type == 'horizontalBar': elements['type'] = 'horizontalBar' self.options['scales'] = { 'xAxes': [ { 'ticks': { 'beginAtZero': 'true' } } ], 'yAxes': [ { 'ticks': { 'beginAtZero': 'true' } } ] } if self.chart_type == 'stackedHorizontalBar': elements['type'] = 'horizontalBar' self.options['scales'] = { 'xAxes': [ {'stacked': 'true'} ], 'yAxes': [ {'stacked': 'true'} ] } if self.chart_type == 'doughnut': elements['type'] = 'doughnut' if self.chart_type == 'polarArea': elements['type'] = 'polarArea' if self.chart_type == 'radar': elements['type'] = 'radar' return elements def get_html(self): code = f'<canvas id="{self.chart_id}"></canvas>' return code def get_js(self): code = f""" var chartElement = document.getElementById('{self.chart_id}').getContext('2d'); var {self.chart_id}Chart = new Chart(chartElement, {self.get_elements()}) """ return code def get_presentation(self): code = { 'html':self.get_html(), 'js': self.get_js(), } return code
Дополнительные материалы:
- 10 лучших материалов для изучения Django
- Самый полный видеокурс по Django от установки до проекта
- 10 трюков библиотеки Python Pandas, которые вам нужны
- Python + Visual Studio Code = успешная разработка
- Осваиваем парсинг сайта: короткий туториал на Python