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

Базовый виджет. Практически ничего не умеет. Но обладает одной полезностью: в момент создания оборачивает метод valuesseries, 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. Спасибо дизайнерам хабра за то, что заголовоки не отличить от текста, а инлайновый код никак не выделяется. Я постараюсь как можно быстрее написать доки, потому что статью читать невозможно.

ссылка на оригинал статьи https://habrahabr.ru/post/278743/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *