Всем привет, хочу поделиться своей небольшой разработкой — 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. Спасибо дизайнерам хабра за то, что заголовоки не отличить от текста, а инлайновый код никак не выделяется. Я постараюсь как можно быстрее написать доки, потому что статью читать невозможно.
ссылка на оригинал статьи https://habrahabr.ru/post/278743/
Добавить комментарий