django-controlcenter

django-controlcenter

Всем привет, хочу поделиться своей небольшой разработкой — django-controlcenter. Это приложение для создания дешбоурдов для вашего django-проекта.

Цель


Django-admin — отличный пример CRUD и невероятно полезное приложение. Вы подключаете модель, а затем видите табличку со всеми записями в базе. Потом вносите вторую, а затем третью и так далее. Со временем у вас набегает много таких табличек: с заказами, комментами, запросами, отзывами — и вы начинаете бегать туда-сюда между всеми ними по несколько раз на дню. А еще иногда хочется всяких графиков.

Django-controlcenter появился как раз из-за подобной ситуации, когда требовалось регулярно проверять несколько моделей на новые записи и игнорировать их, или изменять, или удалять, и видеть динамику в графиках.

Дисклеймер


Текущая версия не использует ajax, и по сути это даже не CRUD, это только Read, но с расширенными возможностями.

Простой пример


Давайте начнем с небольшого примера:

# project/dashboard.py

from controlcenter import Dashboard, widgets
from project.app.models import Model

class ModelItemList(widgets.ItemList):
    model = Model
    list_display = ['pk', 'field']

class MyDashboard(Dashboard):
    widgets = (
        ModelItemList,
    )

# project/settings.py
CONTROLCENTER_DASHBOARDS = [
    'project.dashboards.MyDashboard'
]


Этот виджет выведет табличку в две колонки с 10 последними значениями (по-умолчанияю ItemList ограничен в выдаче, чтобы не порвать вам страницу).

itemlist

Я использовал знакомые термины; в целом, виджет — это смесь Views и ModelAdmin в плане именования методов и атрибутов, и их поведения.

class ModelItemList(widgets.ItemList):
    model = Model
    queryset = model.active_objects.all()
    list_display = ('pk', 'field', 'get_foo')
    list_display_links = ('field', 'get_foo')
    template_name = 'my_custom_template.html'

    def get_foo(self, obj):
        return 'foo'
    get_foo.allow_tags = True
    get_foo.short_description = 'Foo!'


Как видите, ничего нового. Пока еще.

Дисклеймер


Дальше пойдет по сути документация, так что, если вам удобнее разбирать примеры, переходите сразу к ним.

Виджеты


Основных виджета всего три: Widget, ItemList и Chart. Еще есть Group, но это не виджет, а обертка. Начнем с него.

Group


Виджеты могут собираться в группы, тогда они будут переключаться по клику по заголовоку. Для группировки виджеты указываются списком/картежом или используется специальная обертка — Group.

class MyDashboard(Dashboard):
    widgets = (
        Foo,
        (Bar, Baz),
        Group((Egg, Spam), width=widgets.LARGE, height=300,
              attrs={'class': 'my_class', 'data-foo': 'foo'}),
    )


Group принимает три необязательных аргумента: width, height, attrs.
Важный момент: такой «составной» виджет получает высоту самого «высокого» в группе, поскольку, дизайн адаптивный и использует Masonry — если не зафиксировать габариты блока, есть шанс получить забавный эффект, когда переключаясь между виджетами группы у вас будет перестраиваться весь дешбоарад.

Group.width


Сетка дешбоарда адаптивна: до 768px виджеты занимают всю ширину, затем 50% или 100%. От 1000px используется 6-колонная сетка. Для удобства, значения хранятся в модуле widgets:

# controlcenter/widgets.py
MEDIUM = 2   # 33%  или  [x] + [x] + [x]
LARGE = 3    # 50%  или  [  x ] + [ x  ]
LARGER = 4   # 66%  или  [    x  ] + [x]
LARGEST = 6  # 100% или  [      x      ]


Промежуточные значения не особо полезны, но использовать их никто не запрещает.

Group.height


Изначально None, но получив интеджер, выставит виджету это значение как max-height и появится необязательный скролл.
width и height есть и у виджетов, в случае, если эти значения не указаны в Group, берется максимальное значение у виджетов в этой группе.

Group.attrs


Все, что захочется вписать в виджет как html атрибут. Можно даже задать id.

Widget


Базовый виджет. Практически ничего не умеет. Но обладает одной полезностью: в момент создания оборачивает метод values (и series, labels, legend для чартов) в дескриптор cached_property. Соответственно, значения доступны как при обращению к атрибуту (без вызова), а данные кешируются. Это просто небольшое удобство, поскольку приходится часто обращаться к этим методам. Например, для чартов делается такая штука:

def labels(self):
    return [x for x, y in self.values]

def series(self):
    return [y for x, y in self.values]

def values(self):
    return self.get_queryset().values_list('label', 'series')


Еще с десяток раз это спросится в шаблонах, так что лучше сразу все закешировать.

Widget.title


Заголовок виджета. Если не задан, сформируется из названия класса.

Widget.width и Widget.height


Поведение аналогичное Group (см. выше).

Widget.model


Принимает django.db.models.Model.

Widget.get_queryset


Поведение анологичное у django.generic.views:

  • если есть queryset, вернет его.
  • если есть model, вернет его дефолтного менеджера.


Widget.values и Widget.limit_to


Вызывает get_queryset.
Поэтому, если у вас данные «где-то там», переписываем этот метод и забываем про get_queryset. Хоть из файла читайте. Также ограничивает кверисет по значению limit_to, если оно не равно None, вот так: self.get_queryset()[:self.limit_to].

Widget.template_name_prefix


Директория с темплейтами.

Widget.template_name


Имя темплейта.

Widget.get_template_name


Возвращает Widget.template_name_prefix + Widget.template_name.

ItemList


Это самый сложный виджет и одновременно самый простой. Простой, потому что жует все, что не поподя: модели, словари, листы, namedtuple — все, что поддается итерации или имеет доступ по ключу/атрибуту. Однако, есть особенности.

class ModelItemList(widgets.ItemList):
    model = Model
    queryset = model.objects.all()
    list_display = ['pk', 'field']


ItemList.list_display


Во время рендеринга шаблонов значения из элементов в values берутся по ключам из list_display (для моделей, словарей и namedtuple), для последовательностей индекс ключа равен индексу значения, грубо говоря zip(list_display, values).Нумерация строк
Добавьте # в list_display и получите нумерацию строк. Также «решетку» можно заменить на другой символ установив его в качестве значения в settings.CONTROLCENTER_SHARP.

ItemList.list_display_links


Поведение аналогичное list_display_links в django.

Ссылка на редактирование объекта


ItemList пытается повесить ссылку на страницу редактирования объекта в админке, для этого ему нужен класс объекта и первичный ключ. Поэтому виджет будет искать эти данные везде: если values вернет инстанс модели, то вытянет все из него. Если values вернет словарь, список или namedtuple, то понадобится указать ItemList.model, потому что, понятно, больше не откуда. Во всех случаях виджет попытается найти pk или id самостоятельно, но в случае последовательностей это сделать не получится, поэтому виджет будет искать эти ключи в list_display сопоставляя его индекс с индексом значений последовательности.
Кстати, виджет понимает deferred модели, так что можно писать так: queryset = Model.obejcts.defer('field').
Для работы этой фичи модель должна быть зарегистрирована в django-admin.

Ссылка на changelist модели


Иногда недостаточно посмотреть на 10 последнийх значений и надо перейти на страницу модели. ModelAdmin строит такие пути самостоятельно. Но в виджет можно подставить все, что угодно в queryset, поэтому придется помочь. Вариантов несколько:

class ModelItemList(widgets.ItemList):
    model = Model
    # Ссылка на модель
    changelist_url = model

    # То же самое, но с фильтром и сортировкой
    changelist_url = model, {'status__exact': 0, 'o': '-7.-1'}

    # То же самое со строкой
    changelist_url = model, 'status__exact=0&o=-7.-1'

    # Или так
    changelist_url = '/admin/model/'
    changelist_url = 'http://www.yandex.ru'


Для работы этой фичи модель должна быть зарегистрирована в django-admin.

ItemList.sortable


Для того, чтобы сортировать табличку, достаточно указать sortable=True, но помните, что джанга сортирует в базе, а виджет на стороне клиента, поэтому могут случаться казусы, например, если в столбце даты указаны в формате dd.mm. Используется библиотека sortable.js.

ItemList.method.allow_tags и ItemList.method.short_description


Поведение аналогичное джанговским allow_tags и short_description.

ItemList.empty_message


Выведет это значение, если values вернет пустой список.

ItemList.limit_to


По-умолчанию имеет значение 10, чтобы вы себе в ногу не выстрелили.

Chart


Для графиков используется Chartist — это небольшая библиотека… со своими особенностями. Она очень быстрая, просто мгновенная, я просто не мог пройти мимо.

Есть три типа чартов: LINE, BAR, PIE; и соответствующие к ним классы: LineChart, BarChart, PieChart. Плюс несколько дополнительных, об этом позже.

Chart определяет три дополнительных метода: legend, lables, series, которые еще и кешируются. Все три метода должны возвращать json-сериализуемый объект, к коим не относятся генераторы.

class MyChart(widgets.Chart):
    def legend(self):
        return []

    def labels(self):
        return []

    def series(self):
        return []


Chart.legend


Из коробки Chartist не умеет показывать легенду, но без нее никак, поскольку чартист еще и не рисует значения на графике (да, есть такой момент). Легенда поможет в таких случаях.

Chart.labels


Значения на оси x. Должен возвращать последовательность, ни в коем случае не передавайте генератор.

Chart.series


Значения на оси y. Должен возвращать список списков, поскольку на графиках могут быть множественные данные. Опять же, никаких генераторов. Тут есть небольшая «готча», для типа BAR с одним типом значений передается «плоский» список, т.е. не вложенный, при этом устанавливается дополнительная опция для чартиста. Проще всего использовать SingleBarChart — в нем все настроено.

Chart.Chartist


Chart — это виджет с дополнительным классом Chartist внутри на манер Meta или Media в джанге.

class MyChart(Chart):
    class Chartist:
        klass = widgets.LINE
        point_lables = True
        options = {
            'reverseData': True,
            'axisY': {
                'onlyInteger': True,
            },
            'fullWidth': True,
        }


С той лишь разницей, что при использовании Chartist не нужно наследовать родительский класс, т.е. это как бы не классический python inheritance: вы пишете class Chartist:, а не class Chartist(Parent.Chartist): — поля наследуются автоматически. В наследующем классе переписываются все поля, кроме options, который склеивается с родительским, т.е. в дочернем классе можно написать только новые пары ключ/значение, а не Parent.Chartist.options.copy().update({'foo': 'bar'}). Конечно, у этого метода есть и обратная сторона: дефолтные значения, при необходимости, придется переписать.

Важно! Для LineChart установлено 'reverseData': True, которое реверсирует значения labels и series на клиенте. Чаще всего этот тип чартов используется для отображения последних данных и, чтобы вам не пришлось в каждом первом чарте этим заниматься вручную, эта опция включена по-умолчанию.

Chart.Chartist.klass
Определяет тип чарта: widgets.LINE, widgets.BAR, widgets.PIE.Chart.Chartist.point_lables
Подлючается плугин к Chartist, который проставляет значения на графике. Это странно, но дефолтный чартист обходится без значений на самом графике. К сожалению, эта штука работает только с widgets.LINE. В остальных случаях поможет метод legend.Chart.Chartist.options
Словарь, который целиком отправляется в джсон и передается конструктуру чартиста. Все опции описаны на сайте.

Дополнительные классы


В модуле widgets подготовлены еще несколько вспомогательных классов: SingleLineChart, SingleBarChart, SinglePieChart — для простых юзкейсов.

class BlogsChart(widgets.SingleBarChart):
    model = Blog
    values_list = ('name', 'score')


Ну, собсно, и все. Значения name пойдут в ось x, а score в ось y.

Dashboard


Приложение поддерживает до 10 «панелей», которые доступны по адресу: /admin/dashboards/[pk]/ — где pk индекс в списке settings.CONTROLCENTER_DASHBOARDS.

Dashboard.widgets


Принимает список виджетов.

Dashboard.title


Произвольный заголовок. Если не задан, будет сформирован из названия класса.

Dashboard.Media


Класс Media из джанги.

Настройки

#  Список дешбоардов
CONTROLCENTER_DASHBOARDS = []

# Диез для нумерации строк в `ItemList`
CONTROLCENTER_SHARP = '#'

# Цвета для графиков. Используются дефолтные для `Chartist`,
# но еще я подготовил тему в цветах `Material Design`,
# подстваляем `material`.
CONTROLCENTER_CHARTIST_COLORS = 'default'


Примеры!


Давайте сделаем все то же самое, что и на скриншоте.
Создадим проект, назовем его pizzeria, добавим в него приложение pizza.

pizzeria.pizza.models

from __future__ import unicode_literals
from django.db import models

class Pizza(models.Model):
    name = models.CharField(max_length=100, unique=True)

    def __str__(self):
        return self.name

class Restaraunt(models.Model):
    name = models.CharField(max_length=100, unique=True)
    menu = models.ManyToManyField(Pizza, related_name='restaraunts')

    def __str__(self):
        return self.name

class Order(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    restaraunt = models.ForeignKey(Restaraunt, related_name='orders')
    pizza = models.ForeignKey(Pizza, related_name='orders')


Установка

pip install django-controlcenter


Внесем приложения в pizzeria.settings

INSTALLED_APPS = (
    ...
    'controlcenter',
    'pizza',
)

# Забегая вперед
CONTROLCENTER_DASHBOARDS = (
    'pizzeria.dashboards.MyDashboard'
)


Добавим урлы в pizzeria.urls

from django.conf.urls import url
from django.contrib import admin
from controlcenter.views import controlcenter

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^admin/dashboard/', controlcenter.urls),
]


Виджеты


В файле pizzeria.dashboards создадим виджеты:

import datetime
from collections import defaultdict

from controlcenter import app_settings, Dashboard, widgets
from controlcenter.widgets.core import WidgetMeta
from django.db.models import Count
from django.utils import timezone
from django.utils.timesince import timesince

from .pizza.models import Order, Pizza, Restaraunt

class MenuWidget(widgets.ItemList):
    # Этот виджет отображает список пицц, которые были
    # проданы в конкретном ресторане. Мы будем его использовать
    # как базовый, а позже размножим для всех ресторанов.
    model = Pizza
    list_display = ['name', 'ocount']
    list_display_links = ['name']

    # По-умолчанию, в ItemList выборка ограничена, 
    # чтобы вы случайно не вывели всю таблицу в маленькой рамочке.
    limit_to = None

    # Если виджет будет больше 300, появится скролл
    height = 300

    def get_queryset(self):
        # Возвращает список пицц и подсчитывает заказы на сегодня
        restaraunt = super(MenuWidget, self).get_queryset().get()
        today = timezone.now().date()
        return (restaraunt.menu
                          .filter(orders__created__gte=today)
                          .order_by('-ocount')
                          .annotate(ocount=Count('orders')))

class LatestOrdersWidget(widgets.ItemList):
    # Виджет отображает последние 20 заказов
    # в конкретном ресторане
    model = Order
    queryset = (model.objects
                     .select_related('pizza')
                     .filter(created__gte=timezone.now().date())
                     .order_by('pk'))
    # Добавим `#` чтобы разнумеровать список
    list_display = [app_settings.SHARP, 'pk', 'pizza', 'ago']
    list_display_links = ['pk']

    # Включим сортировку и выведем заголовки в таблице
    sortable = True

    # Отобразим последние 20
    limit_to = 20

    # Ограничим виджет по высоте
    height = 300

    # Дату красивенько
    def ago(self, obj):
        return timesince(obj.created)

RESTARAUNTS = [
    'Mama',
    'Ciao',
    'Sicilia',
]

# Используем мета-класс, чтобы построить виджеты.
# Можно, конечно, наследовать первый виджет и ручками определить классы.
# Напомню, конструктор принимает следующие аргументы:
# имя класса, наследуемые классы, атрибуты
menu_widgets = [WidgetMeta('{}MenuWidget'.format(name),
                           (MenuWidget,),
                           {'queryset': Restaraunt.objects.filter(name=name),
                            # Произвольный заголовок
                            'title': name + ' menu',
                            # Ссылка на `changelist` модели с GET параметром
                            'changelist_url': (
                                 Pizza, {'restaraunts__name__exact': name})})
                for name in RESTARAUNTS]

latest_orders_widget = [WidgetMeta(
                           '{}LatestOrders'.format(name),
                           (LatestOrdersWidget,),
                           {'queryset': (LatestOrdersWidget
                                            .queryset
                                            .filter(restaraunt__name=name)),
                            'title': name + ' orders',
                            'changelist_url': (
                                 Order, {'restaraunt__name__exact': name})})
                        for name in RESTARAUNTS]

class RestarauntSingleBarChart(widgets.SingleBarChart):
    # Строит бар-чарт по числу заказов
    title = 'Most popular restaraunt'
    model = Restaraunt

    class Chartist:
        options = {
            # По-умолчанию, Chartist может использовать
            # float как промежуточные значения, это ни к чему
            'onlyInteger': True,
            # Внутренние отступы чарта -- косметика
            'chartPadding': {
                'top': 24,
                'right': 0,
                'bottom': 0,
                'left': 0,
            }
        }

    def legend(self):
        # Выводит в легенде значения оси `y`,
        # поскольку, Chartist не рисует сами значения на графике
        return self.series

    def values(self):
        queryset = self.get_queryset()
        return (queryset.values_list('name')
                        .annotate(baked=Count('orders'))
                        .order_by('-baked')[:self.limit_to])

class PizzaSingleBarChart(RestarauntSingleBarChart):
    # Наследует предыдущий виджет, поскольку,
    # нам нужны те же настройки, кроме типа чарта
    model = Pizza
    limit_to = 3
    title = 'Most popular pizza'

    class Chartist:
        # Заменяет тип чарта
        klass = widgets.PIE

class OrderLineChart(widgets.LineChart):
    # Отображает динамику продаж в ресторанах
    # за последние 7 дней
    title = 'Orders this week'
    model = Order
    limit_to = 7
    # Зададим размерчик побольше
    width = widgets.LARGER

    class Chartist:
        # Настройки чартиста -- косметика
        options = {
            'axisX': {
                'labelOffset': {
                    'x': -24,
                    'y': 0
                },
            },
            'chartPadding': {
                'top': 24,
                'right': 24,
            }
        }

    def legend(self):
        # В легенду пойдут названия ресторанов
        return RESTARAUNTS

    def labels(self):
        # По оси `x` дни
        today = timezone.now().date()
        labels = [(today - datetime.timedelta(days=x)).strftime('%d.%m')
                  for x in range(self.limit_to)]
        return labels

    def series(self):
        # Мы берем даты из `labels`, а данные из базы, где они могут 
        # быть не полными, например, в какой-нибудь день заказов
        # не окажется и это сломает график
        series = []
        for restaraunt in self.legend:
            # Нам нужно убедиться, что если нет значений
            # за нужную дату, там будет стоять 0
            item = self.values.get(restaraunt, {})
            series.append([item.get(label, 0) for label in self.labels])
        return series

    def values(self):
        # Лимит помноженный на число ресторанов
        limit_to = self.limit_to * len(self.legend)
        queryset = self.get_queryset()
        # Вот так в джанге можно сделать `GROUP BY` по двум полям: 
        # названию ресторана и даты.
        # Order.created это datetime, а групировка нужня по дням,
        # использем функцию `DATE` (sqlite3) для конвертации.
        # К сожалению, ORM джанги так устроена, что сортировать 
        # мы должны по тому же полю
        queryset = (queryset.extra({'baked':
                                    'DATE(created)'})
                            .select_related('restaraunt')
                            .values_list('restaraunt__name', 'baked')
                            .order_by('-baked')
                            .annotate(ocount=Count('pk'))[:limit_to])

        # Ключ -- ресторан, значение -- словарь дата:число_заказов
        values = defaultdict(dict)
        for restaraunt, date, count in queryset:
            # DATE в Sqlite3 возвращает стрингу YYYY-MM-DD
            # А в чарте мы хотим видеть DD-MM
            day_month = '{2}.{1}'.format(*date.split('-'))
            values[restaraunt][day_month] = count
        return values


Дешбоарды


django-controlcenter поддерживает до 10 дешбоардов. Но мы создадим один в pizzeria.dashboards

class SimpleDashboard(Dashboard):
    widgets = (
        menu_widgets,
        latest_orders_widget,
        RestarauntSingleBarChart,
        PizzaSingleBarChart,
        OrderLineChart,
    )


Вот и все, открываем /admin/dashboard/0/.

Совместимость


Тесты проводились на python 2.7.9, 3.4.3, 3.5.0 и django 1.8, 1.9.

Name                                               Stmts   Miss  Cover
----------------------------------------------------------------------
controlcenter/__init__.py                              1      0   100%
controlcenter/app_settings.py                         27      0   100%
controlcenter/base.py                                 10      0   100%
controlcenter/dashboards.py                           27      0   100%
controlcenter/templatetags/__init__.py                 0      0   100%
controlcenter/templatetags/controlcenter_tags.py     109      0   100%
controlcenter/utils.py                                16      0   100%
controlcenter/views.py                                39      0   100%
controlcenter/widgets/__init__.py                      2      0   100%
controlcenter/widgets/charts.py                       67      0   100%
controlcenter/widgets/core.py                         93      0   100%
----------------------------------------------------------------------
TOTAL                                                391      0   100%
_______________________________ summary ______________________________
  py27-django18: commands succeeded
  py27-django19: commands succeeded
  py34-django18: commands succeeded
  py34-django19: commands succeeded
  py35-django18: commands succeeded
  py35-django19: commands succeeded


Так же приложение замечательно дружит с django-grappelli.

Документация


Эту статью можно считать таковой, перевод на корявый английский я выполню в ближайшее время, как только разберусь со sphinx.

P.S. Я впервые решил заняться OSP и, надо признаться, больше потратил времени на разбирательства с дистрибьюцией, чем на сам код, и тем не менее я не до конца уверен, что все сделал правильно, поэтому буду признателен за любой фидбек.

P.P. S. Спасибо дизайнерам хабра за то, что заголовоки не отличить от текста, а инлайновый код никак не выделяется. Я постараюсь как можно быстрее написать доки, потому что статью читать невозможно.

© Habrahabr.ru