Projet

Général

Profil

0001-dataviz-render-graph-locally-using-pygal-20771.patch

Frédéric Péters, 11 août 2019 12:54

Télécharger (38,7 ko)

Voir les différences:

Subject: [PATCH] dataviz: render graph locally using pygal (#20771)

 README                                        |   7 +
 combo/apps/dataviz/forms.py                   |  27 +-
 .../migrations/0010_auto_20190328_1111.py     |  45 +++
 combo/apps/dataviz/models.py                  | 157 +++++++-
 .../apps/dataviz/static/js/pygal-tooltips.js  | 353 ++++++++++++++++++
 .../dataviz/templates/combo/chartngcell.html  |  24 ++
 .../templates/combo/chartngcell_form.html     |   8 +
 combo/apps/dataviz/urls.py                    |   4 +-
 combo/apps/dataviz/views.py                   |  18 +-
 combo/settings.py                             |   1 +
 debian/control                                |   4 +-
 setup.py                                      |   2 +
 tests/settings.py                             |   1 +
 tests/test_dataviz.py                         | 216 ++++++++++-
 14 files changed, 858 insertions(+), 9 deletions(-)
 create mode 100644 combo/apps/dataviz/migrations/0010_auto_20190328_1111.py
 create mode 100644 combo/apps/dataviz/static/js/pygal-tooltips.js
 create mode 100644 combo/apps/dataviz/templates/combo/chartngcell.html
 create mode 100644 combo/apps/dataviz/templates/combo/chartngcell_form.html
README
120 120
  License: MIT
121 121
  Comment:
122 122
   From http://bernii.github.io/gauge.js/
123

  
124
Pygal.tooltip.js
125
  Files: combo/apps/dataviz/static/js/pygal.tooltip.js
126
  Copyright: 2015, Florian Mounier Kozea
127
  License: LGPL-3+
128
  Comment:
129
   From https://github.com/Kozea/pygal.js/
combo/apps/dataviz/forms.py
19 19

  
20 20
from combo.utils import requests
21 21

  
22
from .models import ChartCell
22
from .models import ChartCell, ChartNgCell
23 23

  
24 24

  
25 25
class ChartForm(forms.ModelForm):
......
37 37
            available_charts.extend([(x['path'], x['name']) for x in result])
38 38
        available_charts.sort(key=lambda x: x[1])
39 39
        self.fields['url'].widget = forms.Select(choices=available_charts)
40

  
41

  
42
class ChartNgForm(forms.ModelForm):
43
    class Meta:
44
        model = ChartNgCell
45
        fields = ('title', 'data_reference', 'chart_type', 'height')
46

  
47
    def __init__(self, *args, **kwargs):
48
        super(ChartNgForm, self).__init__(*args, **kwargs)
49
        data_references = []
50
        bijoe_sites = settings.KNOWN_SERVICES.get('bijoe').items()
51
        for site_key, site_dict in bijoe_sites:
52
            result = requests.get('/visualization/json/',
53
                    remote_service=site_dict, without_user=True,
54
                    headers={'accept': 'application/json'}).json()
55
            if len(bijoe_sites) > 1:
56
                label_prefix = _('%s: ') % site_dict.get('title')
57
            else:
58
                label_prefix = ''
59
            data_references.extend([
60
                ('%s:%s' % (site_key, x['slug']), '%s%s' % (label_prefix, x['name']))
61
                for x in result])
62

  
63
        data_references.sort(key=lambda x: x[1])
64
        self.fields['data_reference'].widget = forms.Select(choices=data_references)
combo/apps/dataviz/migrations/0010_auto_20190328_1111.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.12 on 2019-03-28 10:11
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import django.db.models.deletion
7
import jsonfield.fields
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    dependencies = [
13
        ('data', '0036_page_sub_slug'),
14
        ('dataviz', '0009_auto_20190617_1214'),
15
    ]
16

  
17
    operations = [
18
        migrations.CreateModel(
19
            name='ChartNgCell',
20
            fields=[
21
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
                ('placeholder', models.CharField(max_length=20)),
23
                ('order', models.PositiveIntegerField()),
24
                ('slug', models.SlugField(blank=True, verbose_name='Slug')),
25
                ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
26
                ('public', models.BooleanField(default=True, verbose_name='Public')),
27
                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
28
                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
29
                ('data_reference', models.CharField(max_length=150, verbose_name='Data')),
30
                ('title', models.CharField(blank=True, max_length=150, verbose_name='Title')),
31
                ('cached_json', jsonfield.fields.JSONField(blank=True, default=dict)),
32
                ('chart_type', models.CharField(choices=[(b'bar', 'Bar'), (b'horizontal-bar', 'Horizontal Bar'), (b'stacked-bar', 'Stacked Bar'), (b'line', 'Line'), (b'pie', 'Pie'), (b'dot', 'Dot'), (b'table', 'Table')], default=b'bar', max_length=20, verbose_name='Chart Type')),
33
                ('height', models.CharField(choices=[(b'150', 'Short (150px)'), (b'250', 'Average (250px)'), (b'350', 'Tall (350px)')], default=b'250', max_length=20, verbose_name='Height')),
34
                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
35
                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
36
            ],
37
            options={
38
                'verbose_name': 'Chart',
39
            },
40
        ),
41
        migrations.AlterModelOptions(
42
            name='chartcell',
43
            options={'verbose_name': 'Chart (legacy)'},
44
        ),
45
    ]
combo/apps/dataviz/models.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import copy
18
import os
19

  
17 20
from django.core.urlresolvers import reverse
18 21
from django.db import models
19 22
from django.utils.translation import ugettext_lazy as _
20 23
from django.conf import settings
21 24

  
25
from jsonfield import JSONField
26
import pygal
27

  
22 28
from combo.data.models import CellBase
23 29
from combo.data.library import register_cell_class
24
from combo.utils import get_templated_url
30
from combo.utils import get_templated_url, requests
25 31

  
26 32

  
27 33
@register_cell_class
......
68 74
    url = models.URLField(_('URL'), max_length=250, blank=True, null=True)
69 75

  
70 76
    class Meta:
71
        verbose_name = _('Chart')
77
        verbose_name = _('Chart (legacy)')
72 78

  
73 79
    @classmethod
74 80
    def is_enabled(self):
75
        return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
81
        return settings.LEGACY_CHART_CELL_ENABLED and hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
76 82

  
77 83
    def get_default_form_class(self):
78 84
        from .forms import ChartForm
......
88 94
        context['title'] = self.title
89 95
        context['url'] = self.url
90 96
        return context
97

  
98

  
99
@register_cell_class
100
class ChartNgCell(CellBase):
101
    data_reference = models.CharField(_('Data'), max_length=150)
102
    title = models.CharField(_('Title'), max_length=150, blank=True)
103
    cached_json = JSONField(blank=True)
104
    chart_type = models.CharField(_('Chart Type'), max_length=20, default='bar',
105
            choices=(
106
                ('bar', _('Bar')),
107
                ('horizontal-bar', _('Horizontal Bar')),
108
                ('stacked-bar', _('Stacked Bar')),
109
                ('line', _('Line')),
110
                ('pie', _('Pie')),
111
                ('dot', _('Dot')),
112
                ('table', _('Table')),
113
            ))
114

  
115
    height = models.CharField(_('Height'), max_length=20, default='250',
116
            choices=(
117
                ('150', _('Short (150px)')),
118
                ('250', _('Average (250px)')),
119
                ('350', _('Tall (350px)')),
120
            ))
121

  
122
    manager_form_template = 'combo/chartngcell_form.html'
123

  
124
    class Meta:
125
        verbose_name = _('Chart')
126

  
127
    @classmethod
128
    def is_enabled(self):
129
        return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
130

  
131
    def get_default_form_class(self):
132
        from .forms import ChartNgForm
133
        return ChartNgForm
134

  
135
    def get_additional_label(self):
136
        return self.title
137

  
138
    def save(self, *args, **kwargs):
139
        if self.data_reference:
140
            site_key, visualization_slug = self.data_reference.split(':')
141
            site_dict = settings.KNOWN_SERVICES['bijoe'][site_key]
142
            response_json = requests.get('/visualization/json/',
143
                    remote_service=site_dict, without_user=True,
144
                    headers={'accept': 'application/json'}).json()
145
            if isinstance(response_json, dict):
146
                # forward compatibility with possible API change
147
                response_json = response_json.get('data')
148
            for visualization in response_json:
149
                slug = visualization.get('slug')
150
                if slug == visualization_slug:
151
                    self.cached_json = visualization
152
        return super(ChartNgCell, self).save(*args, **kwargs)
153

  
154
    def get_cell_extra_context(self, context):
155
        ctx = super(ChartNgCell, self).get_cell_extra_context(context)
156
        if self.chart_type == 'table':
157
            chart = self.get_chart(raise_if_not_cached=not(context.get('synchronous')))
158
            ctx['table'] = chart.render_table(
159
                transpose=bool(chart.axis_count == 2),
160
            )
161
        return ctx
162

  
163
    def get_chart(self, width=None, height=None, raise_if_not_cached=False):
164
        response = requests.get(
165
                self.cached_json['data-url'],
166
                cache_duration=300,
167
                raise_if_not_cached=raise_if_not_cached).json()
168

  
169
        style = pygal.style.DefaultStyle(
170
            font_family='OpenSans, sans-serif',
171
            background='transparent')
172

  
173
        chart = {
174
            'bar': pygal.Bar,
175
            'horizontal-bar': pygal.HorizontalBar,
176
            'stacked-bar': pygal.StackedBar,
177
            'line': pygal.Line,
178
            'pie': pygal.Pie,
179
            'dot': pygal.Dot,
180
            'table': pygal.Bar,
181
            }[self.chart_type](config=pygal.Config(style=copy.copy(style)))
182

  
183
        # normalize axis to have a fake axis when there are no dimensions and
184
        # always a x axis when there is a single dimension.
185
        x_labels = response['axis'].get('x_labels') or []
186
        y_labels = response['axis'].get('y_labels') or []
187
        data = response['data']
188
        if not x_labels and not y_labels:  # unidata
189
            x_labels = ['']
190
            y_labels = ['']
191
            data = [data]
192
            chart.axis_count = 0
193
        elif not x_labels:
194
            x_labels = y_labels
195
            y_labels = ['']
196
            chart.axis_count = 1
197
        elif not y_labels:
198
            y_labels = ['']
199
            chart.axis_count = 1
200
        else:
201
            chart.axis_count = 2
202

  
203
        chart.config.margin = 0
204
        if width:
205
            chart.config.width = width
206
        if height:
207
            chart.config.height = height
208
        if width or height:
209
            chart.config.explicit_size = True
210
        chart.config.js = [os.path.join(settings.STATIC_URL, 'js/pygal-tooltips.js')]
211
        chart.x_labels = x_labels
212

  
213
        chart.show_legend = bool(len(response['axis']) > 1)
214
        # matplotlib tab10 palette
215
        chart.config.style.colors = (
216
                '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728',
217
                '#9467bd', '#8c564b', '#e377c2', '#7f7f7f',
218
                '#bcbd22', '#17becf')
219

  
220
        if self.chart_type == 'dot':
221
            chart.show_legend = False
222
            # use a single colour for dots
223
            chart.config.style.colors = ('#1f77b4',) * len(x_labels)
224

  
225
        if self.chart_type != 'pie':
226
            for i, serie_label in enumerate(y_labels):
227
                if chart.axis_count < 2:
228
                    values = data
229
                else:
230
                    values = [data[i][j] for j in range(len(x_labels))]
231
                chart.add(serie_label, values)
232
        else:
233
            # pie, create a serie by data, to get different colours
234
            values = data
235
            for label, value in zip(x_labels, values):
236
                if not value:
237
                    continue
238
                chart.add(label, value)
239
            chart.show_legend = True
240

  
241
        return chart
combo/apps/dataviz/static/js/pygal-tooltips.js
1
(function() {
2
  var $, get_translation, init, init_svg, matches, padding, r_translation, sibl, svg_ns, tooltip_timeout, xlink_ns;
3

  
4
  svg_ns = 'http://www.w3.org/2000/svg';
5

  
6
  xlink_ns = 'http://www.w3.org/1999/xlink';
7

  
8
  $ = function(sel, ctx) {
9
    if (ctx == null) {
10
      ctx = null;
11
    }
12
    ctx = ctx || document;
13
    return Array.prototype.slice.call(ctx.querySelectorAll(sel), 0).filter(function(e) {
14
      return e !== ctx;
15
    });
16
  };
17

  
18
  matches = function(el, selector) {
19
    return (el.matches || el.matchesSelector || el.msMatchesSelector || el.mozMatchesSelector || el.webkitMatchesSelector || el.oMatchesSelector).call(el, selector);
20
  };
21

  
22
  sibl = function(el, match) {
23
    if (match == null) {
24
      match = null;
25
    }
26
    return Array.prototype.filter.call(el.parentElement.children, function(child) {
27
      return child !== el && (!match || matches(child, match));
28
    });
29
  };
30

  
31
  Array.prototype.one = function() {
32
    return this.length > 0 && this[0] || {};
33
  };
34

  
35
  padding = 5;
36

  
37
  tooltip_timeout = null;
38

  
39
  r_translation = /translate\((\d+)[ ,]+(\d+)\)/;
40

  
41
  get_translation = function(el) {
42
    return (r_translation.exec(el.getAttribute('transform')) || []).slice(1).map(function(x) {
43
      return +x;
44
    });
45
  };
46

  
47
  init = function(ctx) {
48
    var bbox, box, config, el, graph, inner_svg, num, parent, tooltip, tooltip_el, tt, uid, untooltip, xconvert, yconvert, _i, _j, _k, _len, _len1, _len2, _ref, _ref1, _ref2, _ref3;
49
    if ($('svg', ctx).length) {
50
      inner_svg = $('svg', ctx).one();
51
      parent = inner_svg.parentElement;
52
      box = inner_svg.viewBox.baseVal;
53
      bbox = parent.getBBox();
54
      xconvert = function(x) {
55
        return ((x - box.x) / box.width) * bbox.width;
56
      };
57
      yconvert = function(y) {
58
        return ((y - box.y) / box.height) * bbox.height;
59
      };
60
    } else {
61
      xconvert = yconvert = function(x) {
62
        return x;
63
      };
64
    }
65
    if (((_ref = window.pygal) != null ? _ref.config : void 0) != null) {
66
      if (window.pygal.config.no_prefix != null) {
67
        config = window.pygal.config;
68
      } else {
69
        uid = ctx.id.replace('chart-', '');
70
        config = window.pygal.config[uid];
71
      }
72
    } else {
73
      config = window.config;
74
    }
75
    tooltip_el = null;
76
    graph = $('.graph').one();
77
    tt = $('.tooltip', ctx).one();
78
    _ref1 = $('.reactive', ctx);
79
    for (_i = 0, _len = _ref1.length; _i < _len; _i++) {
80
      el = _ref1[_i];
81
      el.addEventListener('mouseenter', (function(el) {
82
        return function() {
83
          return el.classList.add('active');
84
        };
85
      })(el));
86
      el.addEventListener('mouseleave', (function(el) {
87
        return function() {
88
          return el.classList.remove('active');
89
        };
90
      })(el));
91
    }
92
    _ref2 = $('.activate-serie', ctx);
93
    for (_j = 0, _len1 = _ref2.length; _j < _len1; _j++) {
94
      el = _ref2[_j];
95
      num = el.id.replace('activate-serie-', '');
96
      el.addEventListener('mouseenter', (function(num) {
97
        return function() {
98
          var re, _k, _l, _len2, _len3, _ref3, _ref4, _results;
99
          _ref3 = $('.serie-' + num + ' .reactive', ctx);
100
          for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
101
            re = _ref3[_k];
102
            re.classList.add('active');
103
          }
104
          _ref4 = $('.serie-' + num + ' .showable', ctx);
105
          _results = [];
106
          for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
107
            re = _ref4[_l];
108
            _results.push(re.classList.add('shown'));
109
          }
110
          return _results;
111
        };
112
      })(num));
113
      el.addEventListener('mouseleave', (function(num) {
114
        return function() {
115
          var re, _k, _l, _len2, _len3, _ref3, _ref4, _results;
116
          _ref3 = $('.serie-' + num + ' .reactive', ctx);
117
          for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
118
            re = _ref3[_k];
119
            re.classList.remove('active');
120
          }
121
          _ref4 = $('.serie-' + num + ' .showable', ctx);
122
          _results = [];
123
          for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
124
            re = _ref4[_l];
125
            _results.push(re.classList.remove('shown'));
126
          }
127
          return _results;
128
        };
129
      })(num));
130
      el.addEventListener('click', (function(el, num) {
131
        return function() {
132
          var ov, re, rect, show, _k, _l, _len2, _len3, _ref3, _ref4, _results;
133
          rect = $('rect', el).one();
134
          show = rect.style.fill !== '';
135
          rect.style.fill = show ? '' : 'transparent';
136
          _ref3 = $('.serie-' + num + ' .reactive', ctx);
137
          for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
138
            re = _ref3[_k];
139
            re.style.display = show ? '' : 'none';
140
          }
141
          _ref4 = $('.text-overlay .serie-' + num, ctx);
142
          _results = [];
143
          for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
144
            ov = _ref4[_l];
145
            _results.push(ov.style.display = show ? '' : 'none');
146
          }
147
          return _results;
148
        };
149
      })(el, num));
150
    }
151
    _ref3 = $('.tooltip-trigger', ctx);
152
    for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
153
      el = _ref3[_k];
154
      el.addEventListener('mouseenter', (function(el) {
155
        return function() {
156
          return tooltip_el = tooltip(el);
157
        };
158
      })(el));
159
    }
160
    tt.addEventListener('mouseenter', function() {
161
      return tooltip_el != null ? tooltip_el.classList.add('active') : void 0;
162
    });
163
    tt.addEventListener('mouseleave', function() {
164
      return tooltip_el != null ? tooltip_el.classList.remove('active') : void 0;
165
    });
166
    ctx.addEventListener('mouseleave', function() {
167
      if (tooltip_timeout) {
168
        clearTimeout(tooltip_timeout);
169
      }
170
      return untooltip(0);
171
    });
172
    graph.addEventListener('mousemove', function(el) {
173
      if (tooltip_timeout) {
174
        return;
175
      }
176
      if (!matches(el.target, '.background')) {
177
        return;
178
      }
179
      return untooltip(1000);
180
    });
181
    tooltip = function(el) {
182
      var a, baseline, cls, current_x, current_y, dy, h, i, key, keys, label, legend, name, plot_x, plot_y, rect, serie_index, subval, text, text_group, texts, traversal, value, w, x, x_elt, x_label, xlink, y, y_elt, _l, _len3, _len4, _len5, _m, _n, _ref4, _ref5, _ref6, _ref7, _ref8;
183
      clearTimeout(tooltip_timeout);
184
      tooltip_timeout = null;
185
      tt.style.opacity = 1;
186
      tt.style.display = '';
187
      text_group = $('g.text', tt).one();
188
      rect = $('rect', tt).one();
189
      text_group.innerHTML = '';
190
      label = sibl(el, '.label').one().textContent;
191
      x_label = sibl(el, '.x_label').one().textContent;
192
      value = sibl(el, '.value').one().textContent;
193
      xlink = sibl(el, '.xlink').one().textContent;
194
      serie_index = null;
195
      parent = el;
196
      traversal = [];
197
      while (parent) {
198
        traversal.push(parent);
199
        if (parent.classList.contains('series')) {
200
          break;
201
        }
202
        parent = parent.parentElement;
203
      }
204
      if (parent) {
205
        _ref4 = parent.classList;
206
        for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
207
          cls = _ref4[_l];
208
          if (cls.indexOf('serie-') === 0) {
209
            serie_index = +cls.replace('serie-', '');
210
            break;
211
          }
212
        }
213
      }
214
      legend = null;
215
      if (serie_index !== null) {
216
        legend = config.legends[serie_index];
217
      }
218
      dy = 0;
219
      keys = [[label, 'label']];
220
      _ref5 = value.split('\n');
221
      for (i = _m = 0, _len4 = _ref5.length; _m < _len4; i = ++_m) {
222
        subval = _ref5[i];
223
        keys.push([subval, 'value-' + i]);
224
      }
225
      if (config.tooltip_fancy_mode) {
226
        keys.push([xlink, 'xlink']);
227
        keys.unshift([x_label, 'x_label']);
228
        keys.unshift([legend, 'legend']);
229
      }
230
      texts = {};
231
      for (_n = 0, _len5 = keys.length; _n < _len5; _n++) {
232
        _ref6 = keys[_n], key = _ref6[0], name = _ref6[1];
233
        if (key) {
234
          text = document.createElementNS(svg_ns, 'text');
235
          text.textContent = key;
236
          text.setAttribute('x', padding);
237
          text.setAttribute('dy', dy);
238
          text.classList.add(name.indexOf('value') === 0 ? 'value' : name);
239
          if (name.indexOf('value') === 0 && config.tooltip_fancy_mode) {
240
            text.classList.add('color-' + serie_index);
241
          }
242
          if (name === 'xlink') {
243
            a = document.createElementNS(svg_ns, 'a');
244
            a.setAttributeNS(xlink_ns, 'href', key);
245
            a.textContent = void 0;
246
            a.appendChild(text);
247
            text.textContent = 'Link >';
248
            text_group.appendChild(a);
249
          } else {
250
            text_group.appendChild(text);
251
          }
252
          dy += text.getBBox().height + padding / 2;
253
          baseline = padding;
254
          if (text.style.dominantBaseline !== void 0) {
255
            text.style.dominantBaseline = 'text-before-edge';
256
          } else {
257
            baseline += text.getBBox().height * .8;
258
          }
259
          text.setAttribute('y', baseline);
260
          texts[name] = text;
261
        }
262
      }
263
      w = text_group.getBBox().width + 2 * padding;
264
      h = text_group.getBBox().height + 2 * padding;
265
      rect.setAttribute('width', w);
266
      rect.setAttribute('height', h);
267
      if (texts.value) {
268
        texts.value.setAttribute('dx', (w - texts.value.getBBox().width) / 2 - padding);
269
      }
270
      if (texts.x_label) {
271
        texts.x_label.setAttribute('dx', w - texts.x_label.getBBox().width - 2 * padding);
272
      }
273
      if (texts.xlink) {
274
        texts.xlink.setAttribute('dx', w - texts.xlink.getBBox().width - 2 * padding);
275
      }
276
      x_elt = sibl(el, '.x').one();
277
      y_elt = sibl(el, '.y').one();
278
      x = parseInt(x_elt.textContent);
279
      if (x_elt.classList.contains('centered')) {
280
        x -= w / 2;
281
      } else if (x_elt.classList.contains('left')) {
282
        x -= w;
283
      } else if (x_elt.classList.contains('auto')) {
284
        x = xconvert(el.getBBox().x + el.getBBox().width / 2) - w / 2;
285
      }
286
      y = parseInt(y_elt.textContent);
287
      if (y_elt.classList.contains('centered')) {
288
        y -= h / 2;
289
      } else if (y_elt.classList.contains('top')) {
290
        y -= h;
291
      } else if (y_elt.classList.contains('auto')) {
292
        y = yconvert(el.getBBox().y + el.getBBox().height / 2) - h / 2;
293
      }
294
      _ref7 = get_translation(tt.parentElement), plot_x = _ref7[0], plot_y = _ref7[1];
295
      if (x + w + plot_x > config.width) {
296
        x = config.width - w - plot_x;
297
      }
298
      if (y + h + plot_y > config.height) {
299
        y = config.height - h - plot_y;
300
      }
301
      if (x + plot_x < 0) {
302
        x = -plot_x;
303
      }
304
      if (y + plot_y < 0) {
305
        y = -plot_y;
306
      }
307
      _ref8 = get_translation(tt), current_x = _ref8[0], current_y = _ref8[1];
308
      if (current_x === x && current_y === y) {
309
        return el;
310
      }
311
      tt.setAttribute('transform', "translate(" + x + " " + y + ")");
312
      return el;
313
    };
314
    return untooltip = function(ms) {
315
      return tooltip_timeout = setTimeout(function() {
316
        tt.style.display = 'none';
317
        tt.style.opacity = 0;
318
        if (tooltip_el != null) {
319
          tooltip_el.classList.remove('active');
320
        }
321
        return tooltip_timeout = null;
322
      }, ms);
323
    };
324
  };
325

  
326
  init_svg = function() {
327
    var chart, charts, _i, _len, _results;
328
    charts = $('.pygal-chart');
329
    if (charts.length) {
330
      _results = [];
331
      for (_i = 0, _len = charts.length; _i < _len; _i++) {
332
        chart = charts[_i];
333
        _results.push(init(chart));
334
      }
335
      return _results;
336
    }
337
  };
338

  
339
  if (document.readyState !== 'loading') {
340
    init_svg();
341
  } else {
342
    document.addEventListener('DOMContentLoaded', function() {
343
      return init_svg();
344
    });
345
  }
346

  
347
  window.pygal = window.pygal || {};
348

  
349
  window.pygal.init = init;
350

  
351
  window.pygal.init_svg = init_svg;
352

  
353
}).call(this);
combo/apps/dataviz/templates/combo/chartngcell.html
1
{% load i18n %}
2
{% if cell.title %}<h2>{{cell.title}}</h2>{% endif %}
3
{% if cell.chart_type == "table" %}
4
{{table|safe}}
5
{% else %}
6
<div style="min-height: {{cell.height}}px">
7
<embed id="chart-{{cell.id}}" type="image/svg+xml"/>
8
</div>
9
<script>
10
$(function() {
11
  var last_width = 1;
12
  $(window).on('load resize', function() {
13
    var chart_cell = $('#chart-{{cell.id}}').parent();
14
    var new_width = Math.floor($(chart_cell).width());
15
    var ratio = new_width / last_width;
16
    if (ratio > 1.2 || ratio < 0.8) {
17
      $('#chart-{{cell.id}}').attr('src',
18
            "{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + new_width);
19
      last_width = new_width;
20
    }
21
  });
22
});
23
</script>
24
{% endif %}
combo/apps/dataviz/templates/combo/chartngcell_form.html
1
<div style="position: relative">
2
{{ form.as_p }}
3
{% if cell.chart_type != "table" %}
4
<div style="position: absolute; right: 0; top: 0; width: 300px; height: 150px">
5
  <embed type="image/svg+xml" src="{% url 'combo-dataviz-graph' cell=cell.id %}?width=300&height=150"/>
6
</div>
7
{% endif %}
8
</div>
combo/apps/dataviz/urls.py
16 16

  
17 17
from django.conf.urls import url
18 18

  
19
from .views import ajax_gauge_count
19
from .views import ajax_gauge_count, dataviz_graph
20 20

  
21 21
urlpatterns = [
22 22
    url(r'^ajax/gauge-count/(?P<cell>[\w_-]+)/$',
23 23
        ajax_gauge_count, name='combo-ajax-gauge-count'),
24
    url(r'^api/dataviz/graph/(?P<cell>[\w_-]+)/$',
25
        dataviz_graph, name='combo-dataviz-graph'),
24 26
]
combo/apps/dataviz/views.py
1 1
# combo - content management system
2
# Copyright (C) 2015  Entr'ouvert
2
# Copyright (C) 2015-2019  Entr'ouvert
3 3
#
4 4
# This program is free software: you can redistribute it and/or modify it
5 5
# under the terms of the GNU Affero General Public License as published
......
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from django.core.exceptions import PermissionDenied
17 18
from django.http import HttpResponse
18 19

  
19 20
from combo.utils import get_templated_url, requests
20
from .models import Gauge
21
from .models import Gauge, ChartNgCell
21 22

  
22 23

  
23 24
def ajax_gauge_count(request, *args, **kwargs):
24 25
    gauge = Gauge.objects.get(id=kwargs['cell'])
25 26
    response = requests.get(get_templated_url(gauge.data_source))
26 27
    return HttpResponse(response.content, content_type='text/json')
28

  
29

  
30
def dataviz_graph(request, *args, **kwargs):
31
    cell = ChartNgCell.objects.get(id=kwargs.get('cell'))
32
    if not cell.page.is_visible(request.user):
33
        raise PermissionDenied()
34
    if not cell.is_visible(request.user):
35
        raise PermissionDenied()
36
    chart = cell.get_chart(
37
            width=int(request.GET.get('width', 0)) or None,
38
            height=int(request.GET.get('height', 0)) or int(cell.height)
39
    )
40
    return HttpResponse(chart.render(), content_type='image/svg+xml')
combo/settings.py
334 334

  
335 335
# hide work-in-progress/experimental/broken/legacy/whatever cells for now
336 336
BOOKING_CALENDAR_CELL_ENABLED = False
337
LEGACY_CHART_CELL_ENABLED = False
337 338
NEWSLETTERS_CELL_ENABLED = False
338 339
USERSEARCH_CELL_ENABLED = False
339 340

  
debian/control
24 24
    python-django-haystack (>= 2.4.0),
25 25
    python-sorl-thumbnail,
26 26
    python-pil,
27
    python-pywebpush
27
    python-pywebpush,
28
    python-pygal,
29
    python-lxml
28 30
Recommends: python-django-mellon, python-whoosh
29 31
Conflicts: python-lingo
30 32
Description: Portal Management System (Python module)
setup.py
166 166
        'Pillow',
167 167
        'pyproj',
168 168
        'pywebpush',
169
        'pygal',
170
        'lxml',
169 171
        ],
170 172
    zip_safe=False,
171 173
    cmdclass={
tests/settings.py
61 61
FAMILY_SERVICE = {'root': '/'}
62 62

  
63 63
BOOKING_CALENDAR_CELL_ENABLED = True
64
LEGACY_CHART_CELL_ENABLED = True
64 65
NEWSLETTERS_CELL_ENABLED = True
65 66

  
66 67
USER_PROFILE_CONFIG = {
tests/test_dataviz.py
1
import json
2

  
1 3
import mock
2 4
import pytest
5
from httmock import HTTMock
3 6

  
7
from django.contrib.auth.models import User, Group
4 8
from django.test import override_settings
5 9

  
6 10
from combo.data.models import Page
7
from combo.apps.dataviz.models import Gauge
11
from combo.apps.dataviz.models import Gauge, ChartNgCell
12

  
13
from .test_public import login, normal_user
8 14

  
9 15
pytestmark = pytest.mark.django_db
10 16

  
......
37 43
            resp = app.get('/ajax/gauge-count/%s/' % cell.id)
38 44
            assert resp.text == 'xxx'
39 45
            assert requests_get.call_args[0][0] == 'http://www.example.net/XXX'
46

  
47

  
48
VISUALIZATION_JSON = [
49
    {
50
        'data-url': 'https://bijoe.example.com/visualization/1/json/',
51
        'path': 'https://bijoe.example.com/visualization/1/iframe/?signature=123',
52
        'name': 'example visualization (X)',
53
        'slug': 'example',
54
    },
55
    {
56
        'data-url': 'https://bijoe.example.com/visualization/2/json/',
57
        'path': 'https://bijoe.example.com/visualization/2/iframe/?signature=123',
58
        'name': 'second visualization (Y)',
59
        'slug': 'second',
60
    },
61
    {
62
        'data-url': 'https://bijoe.example.com/visualization/3/json/',
63
        'path': 'https://bijoe.example.com/visualization/3/iframe/?signature=123',
64
        'name': 'third visualization (X/Y)',
65
        'slug': 'third',
66
    },
67
    {
68
        'data-url': 'https://bijoe.example.com/visualization/4/json/',
69
        'path': 'https://bijoe.example.com/visualization/4/iframe/?signature=123',
70
        'name': 'fourth visualization (no axis)',
71
        'slug': 'fourth',
72
    },
73
]
74

  
75

  
76
def bijoe_mock(url, request):
77
    if url.path == '/visualization/json/':
78
        return {'content': json.dumps(VISUALIZATION_JSON), 'request': request, 'status_code': 200}
79
    if url.path == '/visualization/1/json/':
80
        response = {
81
            'format': '1',
82
            'data': [222, 134, 53],
83
            'axis': {
84
                'x_labels': ['web', 'mail', 'email']
85
            }
86
        }
87
        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
88
    if url.path == '/visualization/2/json/':
89
        response = {
90
            'format': '1',
91
            'data': [222, 134, 53],
92
            'axis': {
93
                'y_labels': ['web', 'mail', 'email']
94
            }
95
        }
96
        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
97
    if url.path == '/visualization/3/json/':
98
        response = {
99
            'format': '1',
100
            'data': [
101
                [222, 134, 53],
102
                [122, 114, 33],
103
            ],
104
            'axis': {
105
                'x_labels': ['web', 'mail', 'email'],
106
                'y_labels': ['foo', 'bar'],
107
            }
108
        }
109
        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
110
    if url.path == '/visualization/4/json/':
111
        response = {
112
            'format': '1',
113
            'data': 222,
114
            'axis': {}
115
        }
116
        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
117

  
118

  
119
def test_chartng_cell(app):
120
    page = Page(title='One', slug='index')
121
    page.save()
122

  
123
    with override_settings(KNOWN_SERVICES={
124
            'bijoe': {'plop': {'title': 'test', 'url': 'https://bijoe.example.com',
125
                      'secret': 'combo', 'orig': 'combo'}}}):
126
        with HTTMock(bijoe_mock):
127
            cell = ChartNgCell(page=page, order=1)
128
            cell.data_reference = 'plop:example'
129
            cell.save()
130
            assert cell.cached_json == VISUALIZATION_JSON[0]
131

  
132
            # bar
133
            chart = cell.get_chart()
134
            assert chart.__class__.__name__ == 'Bar'
135
            assert chart.x_labels == ['web', 'mail', 'email']
136
            assert chart.raw_series == [([222, 134, 53], {'title': ''})]
137

  
138
            # horizontal bar
139
            cell.chart_type = 'horizontal-bar'
140
            chart = cell.get_chart()
141
            assert chart.__class__.__name__ == 'HorizontalBar'
142
            assert chart.x_labels == ['web', 'mail', 'email']
143
            assert chart.raw_series == [([222, 134, 53], {'title': ''})]
144

  
145
            # pie
146
            cell.chart_type = 'pie'
147
            chart = cell.get_chart()
148
            assert chart.__class__.__name__ == 'Pie'
149
            assert chart.x_labels == ['web', 'mail', 'email']
150
            assert chart.raw_series == [
151
                ([222], {'title': u'web'}),
152
                ([134], {'title': u'mail'}),
153
                ([53], {'title': u'email'})
154
            ]
155

  
156
            # data in Y
157
            cell.chart_type = 'bar'
158
            cell.data_reference = 'plop:second'
159
            cell.save()
160
            assert cell.cached_json == VISUALIZATION_JSON[1]
161

  
162
            chart = cell.get_chart()
163
            assert chart.x_labels == ['web', 'mail', 'email']
164
            assert chart.raw_series == [([222, 134, 53], {'title': ''})]
165

  
166
            # data in X/Y
167
            cell.chart_type = 'bar'
168
            cell.data_reference = 'plop:third'
169
            cell.save()
170
            assert cell.cached_json == VISUALIZATION_JSON[2]
171

  
172
            chart = cell.get_chart()
173
            assert chart.x_labels == ['web', 'mail', 'email']
174
            assert chart.raw_series == [
175
                ([222, 134, 53], {'title': u'foo'}),
176
                ([122, 114, 33], {'title': u'bar'}),
177
            ]
178

  
179
            # single data point
180
            cell.chart_type = 'bar'
181
            cell.data_reference = 'plop:fourth'
182
            cell.save()
183
            assert cell.cached_json == VISUALIZATION_JSON[3]
184

  
185
            chart = cell.get_chart()
186
            assert chart.x_labels == ['']
187
            assert chart.raw_series == [([222], {'title': ''})]
188

  
189
def test_chartng_cell_view(app, normal_user):
190
    page = Page(title='One', slug='index')
191
    page.save()
192

  
193
    with override_settings(KNOWN_SERVICES={
194
            'bijoe': {'plop': {'title': 'test', 'url': 'https://bijoe.example.com',
195
                      'secret': 'combo', 'orig': 'combo'}}}):
196
        with HTTMock(bijoe_mock):
197
            cell = ChartNgCell(page=page, order=1, placeholder='content')
198
            cell.data_reference = 'plop:example'
199
            cell.save()
200
            resp = app.get('/')
201
            assert 'min-height: 250px' in resp.text
202
            assert '/api/dataviz/graph/1/' in resp.text
203

  
204
            resp = app.get('/api/dataviz/graph/1/?width=400')
205
            assert resp.content_type == 'image/svg+xml'
206

  
207
            page.public = False
208
            page.save()
209
            resp = app.get('/api/dataviz/graph/1/?width=400', status=403)
210

  
211
            page.public = True
212
            page.save()
213
            group = Group(name='plop')
214
            group.save()
215
            cell.public = False
216
            cell.groups = [group]
217
            cell.save()
218
            resp = app.get('/api/dataviz/graph/1/?width=400', status=403)
219

  
220
            app = login(app, username='normal-user', password='normal-user')
221
            resp = app.get('/api/dataviz/graph/1/?width=400', status=403)
222

  
223
            normal_user.groups = [group]
224
            normal_user.save()
225
            resp = app.get('/api/dataviz/graph/1/?width=400', status=200)
226

  
227
            # table visualization
228
            cell.chart_type = 'table'
229
            cell.save()
230
            resp = app.get('/')
231
            assert '<td>222</td>' in resp.body
232

  
233

  
234
def test_chartng_cell_manager(app, admin_user):
235
    page = Page(title='One', slug='index')
236
    page.save()
237

  
238
    app = login(app)
239

  
240
    with override_settings(KNOWN_SERVICES={
241
            'bijoe': {'plop': {'title': 'test', 'url': 'https://bijoe.example.com',
242
                      'secret': 'combo', 'orig': 'combo'}}}):
243
        with HTTMock(bijoe_mock):
244
            cell = ChartNgCell(page=page, order=1, placeholder='content')
245
            cell.data_reference = 'plop:example'
246
            cell.save()
247
            resp = app.get('/manage/pages/%s/' % page.id)
248
            assert resp.form['cdataviz_chartngcell-%s-data_reference' % cell.id].options == [
249
                (u'plop:example', True, u'example visualization (X)'),
250
                (u'plop:fourth', False, u'fourth visualization (no axis)'),
251
                (u'plop:second', False, u'second visualization (Y)'),
252
                (u'plop:third', False, u'third visualization (X/Y)'),
253
            ]
40
-