Projet

Général

Profil

0005-dataviz-add-new-filters-cell-60547.patch

Valentin Deniaud, 18 janvier 2022 17:09

Télécharger (22,9 ko)

Voir les différences:

Subject: [PATCH 5/5] dataviz: add new filters cell (#60547)

 combo/apps/dataviz/forms.py                   |  79 +++++++-
 .../migrations/0016_auto_20201215_1624.py     |   4 +-
 .../migrations/0021_chartfilterscell.py       |  49 +++++
 combo/apps/dataviz/models.py                  |  29 ++-
 .../templates/combo/chart-filters.html        |  43 +++++
 .../dataviz/templates/combo/chartngcell.html  |   8 +-
 combo/apps/dataviz/views.py                   |   7 +-
 tests/test_dataviz.py                         | 181 +++++++++++++++++-
 tests/test_manager.py                         |   8 +-
 tests/test_search.py                          |   2 +-
 10 files changed, 395 insertions(+), 15 deletions(-)
 create mode 100644 combo/apps/dataviz/migrations/0021_chartfilterscell.py
 create mode 100644 combo/apps/dataviz/templates/combo/chart-filters.html
combo/apps/dataviz/forms.py
27 27

  
28 28
from combo.utils import cache_during_request, requests, spooler
29 29

  
30
from .models import ChartCell, ChartNgCell
30
from .models import TIME_FILTERS, ChartCell, ChartNgCell
31 31

  
32 32

  
33 33
class ChartForm(forms.ModelForm):
......
169 169
            else:
170 170
                if not date:
171 171
                    self.add_error(template_field, _('Template does not evaluate to a valid date.'))
172

  
173

  
174
class ChartNgPartialForm(ChartFiltersMixin, forms.ModelForm):
175
    class Meta:
176
        model = ChartNgCell
177
        fields = (
178
            'time_range',
179
            'time_range_start',
180
            'time_range_end',
181
        )
182

  
183
    def __init__(self, *args, **kwargs):
184
        super().__init__(*args, **kwargs)
185
        self.fields.update(self.get_filter_fields(self.instance))
186
        for field in self.fields.values():
187
            field.required = False
188

  
189
    def clean(self):
190
        for filter_ in self.instance.statistic.filters:
191
            if filter_['id'] in self.data:
192
                self.instance.filter_params[filter_['id']] = self.cleaned_data.get(filter_['id'])
193

  
194

  
195
class ChartFiltersForm(ChartFiltersMixin, forms.ModelForm):
196
    class Meta:
197
        model = ChartNgCell
198
        fields = (
199
            'time_range',
200
            'time_range_start',
201
            'time_range_end',
202
        )
203
        widgets = {
204
            'time_range_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
205
            'time_range_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
206
        }
207

  
208
    def __init__(self, *args, **kwargs):
209
        page = kwargs.pop('page')
210
        super().__init__(*args, **kwargs)
211
        self.fields['time_range'].choices = BLANK_CHOICE_DASH + TIME_FILTERS
212

  
213
        chart_cells = list(ChartNgCell.objects.filter(page=page, statistic__isnull=False).order_by('order'))
214
        if not chart_cells:
215
            self.fields.clear()
216
            return
217

  
218
        first_cell = chart_cells[0]
219
        for field in self._meta.fields:
220
            self.fields[field].initial = getattr(first_cell, field)
221
        dynamic_fields = self.get_filter_fields(first_cell)
222
        dynamic_fields_values = {k: v for k, v in first_cell.filter_params.items()}
223

  
224
        for cell in chart_cells[1:]:
225
            cell_filter_fields = self.get_filter_fields(cell)
226

  
227
            # keep only common fields
228
            dynamic_fields = {k: v for k, v in dynamic_fields.items() if k in cell_filter_fields}
229

  
230
            # keep only same value fields
231
            for field, value in cell.filter_params.items():
232
                if field in dynamic_fields and value != dynamic_fields_values.get(field):
233
                    del dynamic_fields[field]
234

  
235
            if cell.time_range != first_cell.time_range:
236
                for field in self._meta.fields:
237
                    self.fields.pop(field, None)
238

  
239
            # ensure compatible choices lists
240
            for field_name, field in cell_filter_fields.items():
241
                if field_name in dynamic_fields:
242
                    dynamic_fields[field_name].choices = [
243
                        x for x in dynamic_fields[field_name].choices if x in field.choices
244
                    ]
245
                    if dynamic_fields[field_name].choices == []:
246
                        del dynamic_fields[field_name]
247

  
248
        self.fields.update(dynamic_fields)
combo/apps/dataviz/migrations/0016_auto_20201215_1624.py
2 2

  
3 3
from django.db import migrations, models
4 4

  
5
from combo.apps.dataviz.models import TIME_FILTERS
5
from combo.apps.dataviz.models import TIME_FILTERS, TIME_FILTERS_TEMPLATE
6 6

  
7 7

  
8 8
class Migration(migrations.Migration):
......
17 17
            name='time_range',
18 18
            field=models.CharField(
19 19
                blank=True,
20
                choices=TIME_FILTERS,
20
                choices=TIME_FILTERS + TIME_FILTERS_TEMPLATE,
21 21
                max_length=20,
22 22
                verbose_name='Filtering (time)',
23 23
            ),
combo/apps/dataviz/migrations/0021_chartfilterscell.py
1
# Generated by Django 2.2.19 on 2022-01-18 10:17
2

  
3
import django.db.models.deletion
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('data', '0051_link_cell_max_length'),
11
        ('auth', '0011_update_proxy_permissions'),
12
        ('dataviz', '0020_auto_20220118_1103'),
13
    ]
14

  
15
    operations = [
16
        migrations.CreateModel(
17
            name='ChartFiltersCell',
18
            fields=[
19
                (
20
                    'id',
21
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
22
                ),
23
                ('placeholder', models.CharField(max_length=20)),
24
                ('order', models.PositiveIntegerField()),
25
                ('slug', models.SlugField(blank=True, verbose_name='Slug')),
26
                (
27
                    'extra_css_class',
28
                    models.CharField(
29
                        blank=True, max_length=100, verbose_name='Extra classes for CSS styling'
30
                    ),
31
                ),
32
                (
33
                    'template_name',
34
                    models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
35
                ),
36
                ('public', models.BooleanField(default=True, verbose_name='Public')),
37
                (
38
                    'restricted_to_unlogged',
39
                    models.BooleanField(default=False, verbose_name='Restrict to unlogged users'),
40
                ),
41
                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
42
                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
43
                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
44
            ],
45
            options={
46
                'verbose_name': 'Filters',
47
            },
48
        ),
49
    ]
combo/apps/dataviz/models.py
158 158
        )
159 159

  
160 160

  
161
TIME_FILTERS = (
161
TIME_FILTERS = [
162 162
    ('previous-year', _('Previous year')),
163 163
    ('current-year', _('Current year')),
164 164
    ('next-year', _('Next year')),
......
169 169
    ('current-week', _('Current week')),
170 170
    ('next-week', _('Next week')),
171 171
    ('range', _('Free range (date)')),
172
    ('range-template', _('Free range (template)')),
173
)
172
]
173
TIME_FILTERS_TEMPLATE = [('range-template', _('Free range (template)'))]
174 174

  
175 175

  
176 176
@register_cell_class
......
192 192
        _('Filtering (time)'),
193 193
        max_length=20,
194 194
        blank=True,
195
        choices=TIME_FILTERS,
195
        choices=TIME_FILTERS + TIME_FILTERS_TEMPLATE,
196 196
    )
197 197
    time_range_start = models.DateField(_('From'), null=True, blank=True)
198 198
    time_range_end = models.DateField(_('To'), null=True, blank=True)
......
678 678
        data['x_labels'] = x_labels
679 679
        for i, serie in enumerate(data['series']):
680 680
            serie['data'] = [values[i] for values in aggregates.values()]
681

  
682

  
683
@register_cell_class
684
class ChartFiltersCell(CellBase):
685
    title = _('Filters')
686
    default_template_name = 'combo/chart-filters.html'
687
    max_one_by_page = True
688

  
689
    class Meta:
690
        verbose_name = _('Filters')
691

  
692
    @classmethod
693
    def is_enabled(cls):
694
        return settings.STATISTICS_PROVIDERS
695

  
696
    def get_cell_extra_context(self, context):
697
        from .forms import ChartFiltersForm
698

  
699
        ctx = super().get_cell_extra_context(context)
700
        ctx['form'] = ChartFiltersForm(page=self.page)
701
        return ctx
combo/apps/dataviz/templates/combo/chart-filters.html
1
{% load i18n %}
2

  
3
{% block cell-content %}
4
<h2>{{ cell.title }}</h2>
5

  
6
<div>
7
  {% if form.fields %}
8
  <form method='get' enctype='multipart/form-data' id='chart-filters'>
9
    {{ form.as_p }}
10
    <div class='buttons'>
11
      <button class='submit-button'>{% trans 'Refresh' %}</button>
12
    </div>
13
  </form>
14
  {% else %}
15
  <p>
16
  {% blocktrans trimmed %}
17
  No filters are available. Note that only filters that are shared between all chart cells will appear. Furthermore, in case they have a value, it must be the same accross all cells.
18
  {% endblocktrans %}
19
  </p>
20
  {% endif %}
21
</div>
22

  
23

  
24
<script>
25
  $(function () {
26
    start_field = $('#id_time_range_start');
27
    end_field = $('#id_time_range_end');
28
    $('#id_time_range').change(function() {
29
      if(this.value == 'range') {
30
        start_field.parent().show();
31
        end_field.parent().show();
32
      } else {
33
        start_field.parent().hide();
34
        end_field.parent().hide();
35
      }
36
    }).change();
37
    $('#chart-filters').submit(function(e) {
38
      e.preventDefault();
39
      $(window).trigger('combo:refresh-graphs');
40
    });
41
  });
42
</script>
43
{% endblock %}
combo/apps/dataviz/templates/combo/chartngcell.html
13 13
    var chart_cell = $('#chart-{{cell.id}}').parent();
14 14
    var new_width = Math.floor($(chart_cell).width());
15 15
    var ratio = new_width / last_width;
16
    var filter_params = $('#chart-filters').serialize();
16 17
    if (ratio > 1.2 || ratio < 0.8) {
17 18
      $('#chart-{{cell.id}}').attr('src',
18
            "{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + new_width);
19
            "{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + new_width + '&' + filter_params);
19 20
      last_width = new_width;
20 21
    }
21 22
  }).trigger('combo:resize-graphs');
23
  $(window).on('combo:refresh-graphs', function() {
24
    var filter_params = $('#chart-filters').serialize();
25
    $('#chart-{{cell.id}}').attr('src',
26
          "{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + last_width + '&' + filter_params);
27
  });
22 28
});
23 29
</script>
24 30
{% endif %}
combo/apps/dataviz/views.py
23 23

  
24 24
from combo.utils import get_templated_url, requests
25 25

  
26
from .forms import ChartNgPartialForm
26 27
from .models import ChartNgCell, Gauge, UnsupportedDataSet
27 28

  
28 29

  
......
49 50
        return super().dispatch(request, *args, **kwargs)
50 51

  
51 52
    def get(self, request, *args, **kwargs):
53
        form = ChartNgPartialForm(request.GET, instance=self.cell)
54
        if not form.is_valid():
55
            return self.svg_error(_('Wrong parameters.'))
56

  
52 57
        try:
53
            chart = self.cell.get_chart(
58
            chart = form.instance.get_chart(
54 59
                width=int(request.GET['width']) if request.GET.get('width') else None,
55 60
                height=int(request.GET['height']) if request.GET.get('height') else int(self.cell.height),
56 61
            )
tests/test_dataviz.py
10 10
from httmock import HTTMock, remember_called, urlmatch, with_httmock
11 11
from requests.exceptions import HTTPError
12 12

  
13
from combo.apps.dataviz.models import ChartNgCell, Gauge, Statistic, UnsupportedDataSet
13
from combo.apps.dataviz.models import ChartFiltersCell, ChartNgCell, Gauge, Statistic, UnsupportedDataSet
14 14
from combo.data.models import Page, ValidityInfo
15 15

  
16 16
from .test_public import login
......
1743 1743
    assert len(chart.x_labels) == 1
1744 1744
    assert chart.x_labels == ['W53-2020']
1745 1745
    assert chart.raw_series == [([19], {'title': 'Serie 1'})]
1746

  
1747

  
1748
@with_httmock(new_api_mock)
1749
def test_chart_filters_cell(new_api_statistics, app, admin_user, nocache):
1750
    page = Page.objects.create(title='One', slug='index')
1751
    ChartFiltersCell.objects.create(page=page, order=1, placeholder='content')
1752
    app = login(app)
1753
    resp = app.get('/')
1754
    assert 'No filters are available' in resp.text
1755

  
1756
    # add unconfigured chart
1757
    first_cell = ChartNgCell.objects.create(page=page, order=2, placeholder='content')
1758
    resp = app.get('/')
1759
    assert 'No filters are available' in resp.text
1760

  
1761
    # add statistics to chart
1762
    first_cell.statistic = Statistic.objects.get(slug='one-serie')
1763
    first_cell.save()
1764
    resp = app.get('/')
1765
    assert len(resp.form.fields) == 7
1766
    assert 'time_range_start' in resp.form.fields
1767
    assert 'time_range_end' in resp.form.fields
1768

  
1769
    time_range_field = resp.form['time_range']
1770
    assert time_range_field.value == ''
1771
    assert time_range_field.options == [
1772
        ('', True, '---------'),
1773
        ('previous-year', False, 'Previous year'),
1774
        ('current-year', False, 'Current year'),
1775
        ('next-year', False, 'Next year'),
1776
        ('previous-month', False, 'Previous month'),
1777
        ('current-month', False, 'Current month'),
1778
        ('next-month', False, 'Next month'),
1779
        ('previous-week', False, 'Previous week'),
1780
        ('current-week', False, 'Current week'),
1781
        ('next-week', False, 'Next week'),
1782
        ('range', False, 'Free range (date)'),
1783
    ]
1784

  
1785
    time_interval_field = resp.form['time_interval']
1786
    assert time_interval_field.value == 'month'
1787
    assert time_interval_field.options == [
1788
        ('day', False, 'Day'),
1789
        ('month', True, 'Month'),
1790
        ('year', False, 'Year'),
1791
        ('week', False, 'Week'),
1792
        ('weekday', False, 'Week day'),
1793
    ]
1794

  
1795
    service_field = resp.form['service']
1796
    assert service_field.value == 'chrono'
1797
    assert service_field.options == [
1798
        ('', False, '---------'),
1799
        ('chrono', True, 'Chrono'),
1800
        ('combo', False, 'Combo'),
1801
    ]
1802

  
1803
    ou_field = resp.form['ou']
1804
    assert ou_field.value == ''
1805
    assert ou_field.options == [
1806
        ('', True, '---------'),
1807
        ('default', False, 'Default OU'),
1808
        ('other', False, 'Other OU'),
1809
    ]
1810

  
1811
    # adding new cell with same statistics changes nothing
1812
    cell = ChartNgCell(page=page, order=3, placeholder='content')
1813
    cell.statistic = Statistic.objects.get(slug='one-serie')
1814
    cell.save()
1815
    old_resp = resp
1816
    resp = app.get('/')
1817
    for field in ('time_range', 'time_interval', 'service', 'ou'):
1818
        assert resp.form[field].options == old_resp.form[field].options
1819

  
1820
    # changing one filter value makes it disappear
1821
    cell.filter_params = {'ou': 'default'}
1822
    cell.save()
1823
    resp = app.get('/')
1824
    assert 'ou' not in resp.form.fields
1825
    for field in ('time_range', 'time_interval', 'service'):
1826
        assert resp.form[field].options == old_resp.form[field].options
1827

  
1828
    # setting the same value for the other cell makes it appear again
1829
    first_cell.filter_params = {'ou': 'default'}
1830
    first_cell.save()
1831
    resp = app.get('/')
1832
    assert resp.form['ou'].value == 'default'
1833

  
1834
    # changing statistics type of cell remove some fields
1835
    cell.statistic = Statistic.objects.get(slug='daily')
1836
    cell.save()
1837
    resp = app.get('/')
1838
    assert 'ou' not in resp.form.fields
1839
    assert 'service' not in resp.form.fields
1840
    for field in ('time_range', 'time_interval'):
1841
        assert resp.form[field].options == old_resp.form[field].options
1842

  
1843
    # changing time_interval value makes interval fields disappear
1844
    cell.time_range = 'next-year'
1845
    cell.save()
1846
    old_resp = resp
1847
    resp = app.get('/')
1848
    assert 'time_range' not in resp.form.fields
1849
    assert 'time_range_start' not in resp.form.fields
1850
    assert 'time_range_end' not in resp.form.fields
1851
    assert resp.form['time_interval'].options == old_resp.form['time_interval'].options
1852

  
1853
    # setting the same value for the other cell makes it appear again
1854
    first_cell.time_range = 'next-year'
1855
    first_cell.save()
1856
    resp = app.get('/')
1857
    assert resp.form['time_range'].value == 'next-year'
1858
    assert resp.form['time_interval'].options == old_resp.form['time_interval'].options
1859

  
1860
    # only common choices are shown
1861
    first_cell.statistic.filters[0]['options'].remove({'id': 'day', 'label': 'Day'})
1862
    first_cell.statistic.save()
1863
    resp = app.get('/')
1864
    assert resp.form['time_interval'].options == [
1865
        ('month', True, 'Month'),
1866
        ('year', False, 'Year'),
1867
    ]
1868

  
1869
    # if no common choices exist, field is removed
1870
    first_cell.statistic.filters[0]['options'] = [{'id': 'random', 'label': 'Random'}]
1871
    first_cell.statistic.save()
1872
    resp = app.get('/')
1873
    assert 'time_interval' not in resp.form.fields
1874
    assert resp.form['time_range'].value == 'next-year'
1875

  
1876
    # form is not shown if no common filters exist
1877
    first_cell.time_range = 'current-year'
1878
    first_cell.save()
1879
    resp = app.get('/')
1880
    assert 'No filters are available' in resp.text
1881

  
1882

  
1883
@with_httmock(new_api_mock)
1884
def test_chartng_cell_api_view_get_parameters(app, normal_user, new_api_statistics, nocache):
1885
    page = Page.objects.create(title='One', slug='index')
1886
    cell = ChartNgCell(page=page, order=1, placeholder='content')
1887
    cell.statistic = Statistic.objects.get(slug='one-serie')
1888
    cell.save()
1889

  
1890
    location = '/api/dataviz/graph/%s/' % cell.id
1891
    app.get(location)
1892
    request = new_api_mock.call['requests'][0]
1893
    assert 'time_interval=' not in request.url
1894
    assert 'ou=' not in request.url
1895

  
1896
    cell.filter_params = {'time_interval': 'month', 'ou': 'default'}
1897
    cell.save()
1898
    app.get(location)
1899
    request = new_api_mock.call['requests'][1]
1900
    assert 'time_interval=month' in request.url
1901
    assert 'ou=default' in request.url
1902

  
1903
    app.get(location + '?time_interval=year')
1904
    request = new_api_mock.call['requests'][2]
1905
    assert 'time_interval=year' in request.url
1906
    assert 'ou=default' in request.url
1907

  
1908
    cell.filter_params.clear()
1909
    cell.statistic = Statistic.objects.get(slug='filter-multiple')
1910
    cell.save()
1911
    app.get(location + '?color=green&color=blue')
1912
    request = new_api_mock.call['requests'][3]
1913
    assert 'color=green&color=blue' in request.url
1914

  
1915
    # unknown params
1916
    app.get(location + '?time_interval=month&ou=default')
1917
    request = new_api_mock.call['requests'][4]
1918
    assert 'time_interval=' not in request.url
1919
    assert 'ou=' not in request.url
1920

  
1921
    # wrong params
1922
    resp = app.get(location + '?time_range_start=xxx')
1923
    assert 'Wrong parameters' in resp.text
1924
    assert len(new_api_mock.call['requests']) == 5
tests/test_manager.py
926 926
    resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json')
927 927
    with CaptureQueriesContext(connection) as ctx:
928 928
        resp = resp.form.submit()
929
        assert len(ctx.captured_queries) in [303, 304]
929
        assert len(ctx.captured_queries) in [308, 309]
930 930
    assert Page.objects.count() == 4
931 931
    assert PageSnapshot.objects.all().count() == 4
932 932

  
......
937 937
    resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json')
938 938
    with CaptureQueriesContext(connection) as ctx:
939 939
        resp = resp.form.submit()
940
        assert len(ctx.captured_queries) == 273
940
        assert len(ctx.captured_queries) == 277
941 941
    assert set(Page.objects.get(slug='one').related_cells['cell_types']) == {'data_textcell', 'data_linkcell'}
942 942
    assert Page.objects.count() == 4
943 943
    assert LinkCell.objects.count() == 2
......
2276 2276

  
2277 2277
    with CaptureQueriesContext(connection) as ctx:
2278 2278
        resp2 = resp.click('view', index=1)
2279
        assert len(ctx.captured_queries) == 70
2279
        assert len(ctx.captured_queries) == 71
2280 2280
    assert Page.snapshots.latest('pk').related_cells == {'cell_types': ['data_textcell']}
2281 2281
    assert resp2.text.index('Hello world') < resp2.text.index('Foobar3')
2282 2282

  
......
2337 2337
    resp = resp.click('restore', index=6)
2338 2338
    with CaptureQueriesContext(connection) as ctx:
2339 2339
        resp = resp.form.submit().follow()
2340
        assert len(ctx.captured_queries) == 144
2340
        assert len(ctx.captured_queries) == 146
2341 2341

  
2342 2342
    resp2 = resp.click('See online')
2343 2343
    assert resp2.text.index('Foobar1') < resp2.text.index('Foobar2') < resp2.text.index('Foobar3')
tests/test_search.py
1420 1420
    assert IndexedCell.objects.count() == 50
1421 1421
    with CaptureQueriesContext(connection) as ctx:
1422 1422
        index_site()
1423
        assert len(ctx.captured_queries) == 224
1423
        assert len(ctx.captured_queries) == 225
1424 1424

  
1425 1425
    SearchCell.objects.create(
1426 1426
        page=page, placeholder='content', order=0, _search_services={'data': ['search1']}
1427
-