combo/combo/apps/dataviz/models.py

246 lines
8.7 KiB
Python

# combo - content management system
# Copyright (C) 2014-2015 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import copy
import os
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from jsonfield import JSONField
import pygal
from combo.data.models import CellBase
from combo.data.library import register_cell_class
from combo.utils import get_templated_url, requests
@register_cell_class
class Gauge(CellBase):
title = models.CharField(_('Title'), max_length=150, blank=True, null=True)
url = models.CharField(_('URL'), max_length=150, blank=True, null=True)
data_source = models.CharField(_('Data Source'), max_length=150, blank=True, null=True)
jsonp_data_source = models.BooleanField(_('Use JSONP to get data'), default=True)
max_value = models.PositiveIntegerField(_('Max Value'), blank=True, null=True)
template_name = 'combo/gauge-cell.html'
class Media:
js = ('js/gauge.min.js', 'js/combo.gauge.js')
class Meta:
verbose_name = _('Gauge')
def get_additional_label(self):
return self.title
def is_relevant(self, context):
return bool(self.data_source)
def get_cell_extra_context(self, context):
if self.jsonp_data_source:
data_source_url = get_templated_url(self.data_source)
else:
data_source_url = reverse('combo-ajax-gauge-count', kwargs={'cell': self.id})
return {'cell': self,
'title': self.title,
'url': get_templated_url(self.url) if self.url else None,
'max_value': self.max_value,
'data_source_url': data_source_url,
'jsonp': self.jsonp_data_source,
}
@register_cell_class
class ChartCell(CellBase):
template_name = 'combo/dataviz-chart.html'
title = models.CharField(_('Title'), max_length=150, blank=True, null=True)
url = models.URLField(_('URL'), max_length=250, blank=True, null=True)
class Meta:
verbose_name = _('Chart (legacy)')
@classmethod
def is_enabled(self):
return settings.LEGACY_CHART_CELL_ENABLED and hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
def get_default_form_class(self):
from .forms import ChartForm
return ChartForm
def get_additional_label(self):
if self.title:
return self.title
return ''
def get_cell_extra_context(self, context):
context = super(ChartCell, self).get_cell_extra_context(context)
context['title'] = self.title
context['url'] = self.url
return context
@register_cell_class
class ChartNgCell(CellBase):
data_reference = models.CharField(_('Data'), max_length=150)
title = models.CharField(_('Title'), max_length=150, blank=True)
cached_json = JSONField(blank=True)
chart_type = models.CharField(_('Chart Type'), max_length=20, default='bar',
choices=(
('bar', _('Bar')),
('horizontal-bar', _('Horizontal Bar')),
('stacked-bar', _('Stacked Bar')),
('line', _('Line')),
('pie', _('Pie')),
('dot', _('Dot')),
('table', _('Table')),
))
height = models.CharField(_('Height'), max_length=20, default='250',
choices=(
('150', _('Short (150px)')),
('250', _('Average (250px)')),
('350', _('Tall (350px)')),
))
manager_form_template = 'combo/chartngcell_form.html'
class Meta:
verbose_name = _('Chart')
@classmethod
def is_enabled(self):
return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
def get_default_form_class(self):
from .forms import ChartNgForm
return ChartNgForm
def get_additional_label(self):
return self.title
def is_relevant(self, context=None):
return bool(self.data_reference)
def save(self, *args, **kwargs):
if self.data_reference:
site_key, visualization_slug = self.data_reference.split(':')
site_dict = settings.KNOWN_SERVICES['bijoe'][site_key]
response_json = requests.get('/visualization/json/',
remote_service=site_dict, without_user=True,
headers={'accept': 'application/json'}).json()
if isinstance(response_json, dict):
# forward compatibility with possible API change
response_json = response_json.get('data')
for visualization in response_json:
slug = visualization.get('slug')
if slug == visualization_slug:
self.cached_json = visualization
return super(ChartNgCell, self).save(*args, **kwargs)
def get_cell_extra_context(self, context):
ctx = super(ChartNgCell, self).get_cell_extra_context(context)
if self.chart_type == 'table':
chart = self.get_chart(raise_if_not_cached=not(context.get('synchronous')))
ctx['table'] = chart.render_table(
transpose=bool(chart.axis_count == 2),
)
ctx['table'] = ctx['table'].replace('<table>', '<table class="main">')
return ctx
def get_chart(self, width=None, height=None, raise_if_not_cached=False):
response = requests.get(
self.cached_json['data-url'],
cache_duration=300,
raise_if_not_cached=raise_if_not_cached).json()
style = pygal.style.DefaultStyle(
font_family='OpenSans, sans-serif',
background='transparent')
chart = {
'bar': pygal.Bar,
'horizontal-bar': pygal.HorizontalBar,
'stacked-bar': pygal.StackedBar,
'line': pygal.Line,
'pie': pygal.Pie,
'dot': pygal.Dot,
'table': pygal.Bar,
}[self.chart_type](config=pygal.Config(style=copy.copy(style)))
# normalize axis to have a fake axis when there are no dimensions and
# always a x axis when there is a single dimension.
x_labels = response['axis'].get('x_labels') or []
y_labels = response['axis'].get('y_labels') or []
data = response['data']
if not x_labels and not y_labels: # unidata
x_labels = ['']
y_labels = ['']
data = [data]
chart.axis_count = 0
elif not x_labels:
x_labels = y_labels
y_labels = ['']
chart.axis_count = 1
elif not y_labels:
y_labels = ['']
chart.axis_count = 1
else:
chart.axis_count = 2
chart.config.margin = 0
if width:
chart.config.width = width
if height:
chart.config.height = height
if width or height:
chart.config.explicit_size = True
chart.config.js = [os.path.join(settings.STATIC_URL, 'js/pygal-tooltips.js')]
chart.x_labels = x_labels
chart.show_legend = bool(len(response['axis']) > 1)
# matplotlib tab10 palette
chart.config.style.colors = (
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728',
'#9467bd', '#8c564b', '#e377c2', '#7f7f7f',
'#bcbd22', '#17becf')
if self.chart_type == 'dot':
chart.show_legend = False
# use a single colour for dots
chart.config.style.colors = ('#1f77b4',) * len(x_labels)
if self.chart_type != 'pie':
for i, serie_label in enumerate(y_labels):
if chart.axis_count < 2:
values = data
else:
values = [data[i][j] for j in range(len(x_labels))]
chart.add(serie_label, values)
else:
# pie, create a serie by data, to get different colours
values = data
for label, value in zip(x_labels, values):
if not value:
continue
chart.add(label, value)
chart.show_legend = True
return chart