📜 ⬆️ ⬇️

django-controlcenter

django-controlcenter

Hello everyone, I want to share my small development - django-controlcenter . This is an application for creating dashboards for your django project.

purpose


Django-admin is a great example of CRUD and an incredibly useful application. You connect the model, and then see the plate with all the records in the database. Then make the second, and then the third and so on. Over time, you have a lot of such plates: with orders, comments, requests, reviews - and you start running back and forth between all of them several times a day. And sometimes you want all sorts of graphs.

Django-controlcenter appeared just because of a similar situation, when it was required to regularly check several models for new records and ignore them, or change, or delete, and see the dynamics in the graphs.
')

Disclaimer


The current version does not use ajax, and in fact it is not even CRUD , it is only Read , but with advanced features.

Simple example


Let's start with a small example:

# 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' ] 

This widget will display a label in two columns with the last 10 values ​​(by default ItemList limited in output so as not to tear the page down to you).

itemlist

I used familiar terms; In general, a widget is a mixture of Views and ModelAdmin in terms of naming methods and attributes, and their behavior.

 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!' 

As you can see, nothing new. Yet .

Disclaimer


Then the documentation will go essentially, so if you prefer to sort out the examples, go straight to them.

Widgets


There are only three main widgets: Widget , ItemList and Chart . There is also a Group , but this is not a widget, but a wrapper. Let's start with it.

Group


Widgets can be assembled into groups, then they will be switched by clicking on the title. For grouping, widgets are indicated by a list / card or a special wrapper is used - Group .

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

Group takes three optional arguments: width , height , attrs .
Important point: such a "composite" widget gets the height of the "highest" in the group, because the design is adaptive and uses Masonry - if you do not fix the dimensions of the block, there is a chance to get a funny effect when switching between the widgets of the group, you will have to rebuild the whole board.

Group.width


The dashboard grid is adaptive: up to 768px widgets occupy the entire width, then 50% or 100% . From 1000px used 6-column grid. For convenience, the values ​​are stored in the widgets module:

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

Intermediate values ​​are not particularly useful, but nobody prohibits their use.

Group.height


Initially None , but having received an integer, set this value to the widget as max-height and an optional scroll appears.
Widgets also have a width and height , in case these values ​​are not specified in the Group , the maximum value is taken from the widgets in this group.

Group.attrs


Anything you want to write in the widget as an html attribute. You can even set the id .

Widget


Basic widget. Virtually nothing can. But it has one utility: at the time of creation, it wraps the values method (and series , labels , legend for charts) in the cached_property descriptor. Accordingly, values ​​are available as when accessing an attribute (without calling), and data is cached. This is just a small convenience, as you often need to refer to these methods. For example, the following is done for charts:

 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') 

A dozen times this will be asked in the templates, so it is better to cache everything at once.

Widget.title


The title of the widget. If not specified, formed from the class name.

Widget.width and Widget.height


The behavior is similar to the Group (see above).

Widget.model


Accepts django.db.models.Model .

Widget.get_queryset


Behavior is similar to django.generic.views :


Widget.values ​​and Widget.limit_to


Calls get_queryset .
Therefore, if you have data somewhere out there, rewrite this method and forget about get_queryset . At least from the file read. Also limits the queriset to limit_to if it is not None , like this: self.get_queryset()[:self.limit_to] .

Widget.template_name_prefix


Directory with templates.

Widget.template_name


Template name.

Widget.get_template_name


Returns Widget.template_name_prefix + Widget.template_name .

Itemlist


This is the most complex widget and at the same time the easiest. Simple, because it chews everything that does not work: models, dictionaries, sheets, namedtuple - everything that can be iterated or has access by key / attribute. However, there are features.

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

ItemList.list_display


During template rendering, values ​​from elements in values are taken from the keys of list_display (for models, dictionaries and namedtuple), for sequences the key index is equal to the value index, roughly speaking zip(list_display, values) .

Line numbering

Add # to list_display and get line numbering. Also, the "grid" can be replaced with another symbol by setting it as a value in settings.CONTROLCENTER_SHARP .

ItemList.list_display_links


The behavior is similar to django's list_display_links .

Link to edit object


ItemList tries to hang a link to the object editing page in the admin panel, for this it needs an object class and a primary key. Therefore, the widget will search for this data everywhere: if values return the model instance, it will pull everything out of it. If values returns a dictionary, a list or a namedtuple , then you will need to specify ItemList.model , because, of course, no longer where. In all cases, the widget will try to find pk or id its own, but in the case of sequences, this will not work, so the widget will look for these keys in list_display matching its index with the index of sequence values.
By the way, the widget understands deferred models, so you can write like this: queryset = Model.obejcts.defer('field') .
For this feature to work, the model must be registered with django-admin .

Model changelist link


Sometimes it is not enough to look at the last 10 values ​​and you need to go to the model page. ModelAdmin builds such paths on its own. But in the widget you can substitute anything in the queryset , so you have to help. There are several options:

 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' 

For this feature to work, the model must be registered with django-admin .

ItemList.sortable


In order to sort the label, it is enough to specify sortable=True , but remember that janga sorts in the database, and the widget on the client side, so incidents can occur, for example, if the dates in the column are in the dd.mm format. The sortable.js library is used .

ItemList.method.allow_tags and ItemList.method.short_description


Behavior is similar to Jung's allow_tags and short_description .

ItemList.empty_message


Displays this value if values returns an empty list.

ItemList.limit_to


The default value is 10 , so you don't shoot yourself in the leg.

Chart


Chartist is used for charts - it is a small library ... with its own features. It is very fast, just instant, I just could not pass by.

There are three types of charts: LINE , BAR , PIE ; and their respective classes: LineChart , BarChart , PieChart . Plus a few extras, more on that later.

Chart defines three additional methods: legend , lables , series , which are also cached. All three methods must return a json-serializable object that does not include generators.

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

Chart.legend


Out of the box, Chartist does not know how to show a legend, but without it there is no way, since the Chartist still does not draw the values ​​on the chart (yes, there is such a moment). Legend will help in such cases.

Chart.labels


Values ​​on the x axis. Should return a sequence, in no case do not pass the generator.

Chart.series


Values ​​on the y axis. Must return a list of lists, since there may be multiple data in the graphs. Again, no generators. There is a small “Gotcha” here, for the type BAR with one type of value, the “flat” list is transferred, i.e. not nested, this sets an additional option for the Chartist. The easiest way to use SingleBarChart - everything is set up in it.

Chart.Chartist


Chart is a widget with an additional Chartist class inside in Meta or Media style in jang.

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

The only difference is that when using Chartist not necessary to inherit the parent class, i.e. this is not the classic python inheritance: you write class Chartist: not class Chartist(Parent.Chartist): - fields are inherited automatically. In the inheriting class, all fields are rewritten, except options , which is glued to the parent, i.e. in the child class, you can write only new key / value pairs, not Parent.Chartist.options.copy().update({'foo': 'bar'}) . Of course, this method has a downside: the default values, if necessary, will have to be rewritten.

Important! LineChart set to 'reverseData': True , which reverses the values ​​of labels and series on the client. Most often, this type of charts is used to display the latest data, and so that you do not have to do it manually in every first chart, this option is enabled by default.

Chart.Chartist.klass

Specifies the type of charts: widgets.LINE , widgets.BAR , widgets.PIE .

Chart.Chartist.point_lables

The plug-in is connected to the Chartist , which Chartist the values ​​on the chart. This is strange, but the default Chartist does without values ​​on the chart itself. Unfortunately, this thing only works with widgets.LINE . In other cases, the legend method will help.

Chart.Chartist.options

The dictionary, which is sent entirely to Json and is transmitted to the Chartist structure. All options are described on the site .

Additional classes


In the widgets module, several more auxiliary classes are SingleLineChart : SingleLineChart , SingleBarChart , SinglePieChart - for simple use cases.

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

Well, sobsno, and all. The name values ​​will go to the x axis, and the score to the y axis.

Dashboard


The application supports up to 10 "panels", which are available at: /admin/dashboards/[pk]/ - where pk index in the list of settings.CONTROLCENTER_DASHBOARDS .

Dashboard.widgets


Accepts a list of widgets.

Dashboard.title


Arbitrary title If not specified, it will be formed from the class name.

Dashboard.Media


Media class from jungle.

Settings


 #   CONTROLCENTER_DASHBOARDS = [] #      `ItemList` CONTROLCENTER_SHARP = '#' #   .    `Chartist`, #        `Material Design`, #  `material`. CONTROLCENTER_CHARTIST_COLORS = 'default' 

Examples!


Let's do the same thing as in the screenshot.
Create a project, call it pizzeria , add the pizza application to it.

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 Restaurant(models.Model): name = models.CharField(max_length=100, unique=True) menu = models.ManyToManyField(Pizza, related_name='restaurants') def __str__(self): return self.name class Order(models.Model): created = models.DateTimeField(auto_now_add=True) restaurant = models.ForeignKey(Restaurant, related_name='orders') pizza = models.ForeignKey(Pizza, related_name='orders') 

Installation


 pip install django-controlcenter 

pizzeria.settings applications to pizzeria.settings

 INSTALLED_APPS = ( ... 'controlcenter', 'pizza', ) #   CONTROLCENTER_DASHBOARDS = ( 'pizzeria.dashboards.MyDashboard' ) 

Add pizzeria.urls to 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), ] 

Widgets


In the pizzeria.dashboards file pizzeria.dashboards create widgets:

 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, Restaurant 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): #         restaurant = super(MenuWidget, self).get_queryset().get() today = timezone.now().date() return (restaurant.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) RESTAURANTS = [ 'Mama', 'Ciao', 'Sicilia', ] #  -,   . # , ,       . # ,    : #  ,  ,  menu_widgets = [WidgetMeta('{}MenuWidget'.format(name), (MenuWidget,), {'queryset': Restaurant.objects.filter(name=name), #   'title': name + ' menu', #   `changelist`   GET  'changelist_url': ( Pizza, {'restaurants__name__exact': name})}) for name in RESTAURANTS] latest_orders_widget = [WidgetMeta( '{}LatestOrders'.format(name), (LatestOrdersWidget,), {'queryset': (LatestOrdersWidget .queryset .filter(restaurant__name=name)), 'title': name + ' orders', 'changelist_url': ( Order, {'restaurant__name__exact': name})}) for name in RESTAURANTS] class RestaurantSingleBarChart(widgets.SingleBarChart): #  -    title = 'Most popular restaurant' model = Restaurant 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(RestaurantSingleBarChart): #   , , #     ,    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 RESTAURANTS 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 restaurant in self.legend: #   ,     #   ,    0 item = self.values.get(restaurant, {}) 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('restaurant') .values_list('restaurant__name', 'baked') .order_by('-baked') .annotate(ocount=Count('pk'))[:limit_to]) #  -- ,  --  :_ values = defaultdict(dict) for restaurant, date, count in queryset: # DATE  Sqlite3   YYYY-MM-DD #       DD-MM day_month = '{2}.{1}'.format(*date.split('-')) values[restaurant][day_month] = count return values 

Dashbarda


django-controlcenter supports up to 10 lowboards. But we will create one in pizzeria.dashboards

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

That's it, open /admin/dashboard/0/ .

Compatibility


Tests were conducted on python 2.7.9, 3.4.3, 3.5.0 and 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 

The same application is great friends with django-grappelli .

Documentation


This article can be considered as such, I will complete the translation into clumsy English in the near future, as soon as I deal with sphinx.

PS I decided to do OSP for the first time and, I must admit, I spent more time on the proceedings with the distribution than on the code itself, and nevertheless I am not completely sure that I did everything correctly, so I would appreciate any feedback.

PPS Thanks to the designers of Habr for the fact that the headings can not be distinguished from the text, and the inline code does not stand out. I will try to write the docks as quickly as possible, because the article cannot be read.

Source: https://habr.com/ru/post/278743/


All Articles