Евгений Левада 18 ноября 2020

📊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
    

Дополнительные материалы:

Источники

МЕРОПРИЯТИЯ

Комментарии 0

ВАКАНСИИ

Unity Developer
Москва, по итогам собеседования
Technical Lead
от 250000 RUB
QA-специалист
от 70000 RUB до 90000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

BUG