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
ограничен в выдаче, чтобы не порвать вам страницу).
Я использовал знакомые термины; в целом, виджет — это смесь 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. Спасибо дизайнерам хабра за то, что заголовоки не отличить от текста, а инлайновый код никак не выделяется. Я постараюсь как можно быстрее написать доки, потому что статью читать невозможно.