314 lines
12 KiB
Python
314 lines
12 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.encoding import force_text
|
|
from django.utils.translation import ugettext_lazy as _, ungettext, gettext
|
|
from django.conf import settings
|
|
|
|
from jsonfield import JSONField
|
|
from requests.exceptions import HTTPError
|
|
import pygal
|
|
|
|
from combo.data.models import CellBase
|
|
from combo.data.library import register_cell_class
|
|
from combo.utils import get_templated_url, requests
|
|
|
|
|
|
class UnsupportedDataSet(Exception):
|
|
pass
|
|
|
|
|
|
@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' and self.cached_json:
|
|
try:
|
|
chart = self.get_chart(raise_if_not_cached=not(context.get('synchronous')))
|
|
except UnsupportedDataSet:
|
|
ctx['table'] = '<p>%s</p>' % _('Unsupported dataset.')
|
|
except HTTPError as e:
|
|
if e.response.status_code == 404:
|
|
ctx['table'] = '<p>%s</p>' % _('Visualization not found.')
|
|
else:
|
|
ctx['table'] = chart.render_table(
|
|
transpose=bool(chart.axis_count == 2),
|
|
total=chart.compute_sum,
|
|
)
|
|
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)
|
|
response.raise_for_status()
|
|
response = response.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.
|
|
data = response['data']
|
|
loop_labels = response['axis'].get('loop') or []
|
|
x_labels = response['axis'].get('x_labels') or []
|
|
y_labels = response['axis'].get('y_labels') or []
|
|
if loop_labels:
|
|
if x_labels and y_labels:
|
|
# no support for three dimensions
|
|
raise UnsupportedDataSet()
|
|
if not y_labels:
|
|
y_labels = loop_labels
|
|
else:
|
|
x_labels, y_labels = y_labels, loop_labels
|
|
if len(y_labels) != len(data) or not all([len(x) == len(x_labels) for x in data]):
|
|
# varying dimensions
|
|
raise UnsupportedDataSet()
|
|
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)
|
|
chart.truncate_legend = 30
|
|
# 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)
|
|
|
|
chart.compute_sum = bool(response.get('measure') == 'integer')
|
|
if chart.compute_sum and self.chart_type == 'table':
|
|
if chart.axis_count < 2: # workaround pygal
|
|
chart.compute_sum = False
|
|
if chart.axis_count == 1:
|
|
data.append(sum(data))
|
|
x_labels.append(gettext('Total'))
|
|
|
|
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)
|
|
if width and width < 500:
|
|
chart.legend_at_bottom = True
|
|
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
|
|
if width and width < 500:
|
|
chart.truncate_legend = 15
|
|
|
|
if response.get('unit') == 'seconds' or response.get('measure') == 'duration':
|
|
def format_duration(value):
|
|
if value is None:
|
|
return '-'
|
|
days = value // 86400
|
|
hours = (value % 86400) // 3600
|
|
if days:
|
|
days_string = ungettext('%d day', '%d days', days) % days
|
|
if hours:
|
|
hours_string = ungettext('%d hour', '%d hours', hours) % hours
|
|
if days and hours:
|
|
value = _('%(days_string)s and %(hours_string)s') % {
|
|
'days_string': days_string,
|
|
'hours_string': hours_string,
|
|
}
|
|
elif days:
|
|
value = days_string
|
|
elif hours:
|
|
value = hours_string
|
|
else:
|
|
value = _('Less than an hour')
|
|
return force_text(value)
|
|
chart.config.value_formatter = format_duration
|
|
elif response.get('measure') == 'percent':
|
|
percent_formatter = lambda x: '{:.1f}%'.format(x)
|
|
chart.config.value_formatter = percent_formatter
|
|
|
|
return chart
|