Projet

Général

Profil

0001-dataviz-allow-page-variable-as-filter-value-57616.patch

Valentin Deniaud, 10 février 2022 15:34

Télécharger (18,8 ko)

Voir les différences:

Subject: [PATCH] dataviz: allow page variable as filter value (#57616)

 combo/apps/dataviz/forms.py                   |  22 +-
 combo/apps/dataviz/models.py                  |  42 +++-
 .../dataviz/templates/combo/chartngcell.html  |  20 +-
 combo/apps/dataviz/views.py                   |  20 +-
 combo/utils/spooler.py                        |   7 +-
 tests/test_dataviz.py                         | 189 ++++++++++++++++++
 6 files changed, 283 insertions(+), 17 deletions(-)
combo/apps/dataviz/forms.py
70 70
            choices = [(option['id'], option['label']) for option in filter_['options']]
71 71
            initial = cell.filter_params.get(filter_id, filter_.get('default'))
72 72

  
73
            possible_choices = {choice[0] for choice in choices}
74
            for choice in initial if isinstance(initial, list) else [initial]:
75
                if choice and choice not in possible_choices:
76
                    choices.append((choice, _('%s (unavailable)') % choice))
77

  
78 73
            required = filter_.get('required', False)
79 74
            multiple = filter_.get('multiple')
80 75
            if not required and not multiple:
81 76
                choices = BLANK_CHOICE_DASH + choices
82 77

  
78
            extra_variables = cell.page.get_extra_variables_keys()
79
            variable_choices = [('variable:' + key, key) for key in extra_variables]
80

  
81
            possible_choices = {choice[0] for choice in choices}
82
            for choice in initial if isinstance(initial, list) else [initial]:
83
                if not choice:
84
                    continue
85
                if choice.startswith('variable:'):
86
                    variable = choice.replace('variable:', '')
87
                    if not variable in extra_variables:
88
                        variable_choices.append((choice, _('%s (unavailable)') % variable))
89
                elif choice not in possible_choices:
90
                    choices.append((choice, _('%s (unavailable)') % choice))
91

  
92
            if variable_choices and not multiple and filter_id != 'time_interval':
93
                choices.append((_('Page variables'), variable_choices))
94

  
83 95
            field_class = forms.MultipleChoiceField if multiple else forms.ChoiceField
84 96
            fields[filter_id] = field_class(
85 97
                label=filter_['label'], choices=choices, required=required, initial=initial
combo/apps/dataviz/models.py
25 25
from django.conf import settings
26 26
from django.contrib.postgres.fields import JSONField
27 27
from django.db import models, transaction
28
from django.template import Context, Template
28
from django.template import Context, RequestContext, Template, TemplateSyntaxError, VariableDoesNotExist
29 29
from django.template.defaultfilters import date as format_date
30 30
from django.urls import reverse
31 31
from django.utils import timezone
32 32
from django.utils.dates import WEEKDAYS
33 33
from django.utils.encoding import force_text
34
from django.utils.functional import cached_property
34 35
from django.utils.translation import gettext
35 36
from django.utils.translation import ugettext_lazy as _
36 37
from django.utils.translation import ungettext
......
45 46
    pass
46 47

  
47 48

  
49
class MissingRequest(Exception):
50
    pass
51

  
52

  
53
class MissingVariable(Exception):
54
    pass
55

  
56

  
48 57
@register_cell_class
49 58
class Gauge(CellBase):
50 59
    title = models.CharField(_('Title'), max_length=150, blank=True, null=True)
......
298 307
    def get_cell_extra_context(self, context):
299 308
        ctx = super().get_cell_extra_context(context)
300 309
        if self.chart_type == 'table' and self.statistic and self.statistic.url:
310
            self._context = context
301 311
            try:
302 312
                chart = self.get_chart(raise_if_not_cached=not (context.get('synchronous')))
303 313
            except UnsupportedDataSet:
304 314
                ctx['table'] = '<p>%s</p>' % _('Unsupported dataset.')
315
            except MissingVariable:
316
                ctx['table'] = '<p>%s</p>' % _('Page variable not found.')
317
            except TemplateSyntaxError:
318
                ctx['table'] = '<p>%s</p>' % _('Syntax error in page variable.')
319
            except VariableDoesNotExist:
320
                ctx['table'] = '<p>%s</p>' % _('Cannot evaluate page variable.')
305 321
            except HTTPError as e:
306 322
                if e.response.status_code == 404:
307 323
                    ctx['table'] = '<p>%s</p>' % _('Visualization not found.')
......
390 406
        return chart
391 407

  
392 408
    def get_filter_params(self):
393
        params = {k: v for k, v in self.filter_params.items() if v}
409
        params = {k: self.evaluate_filter_value(v) for k, v in self.filter_params.items() if v}
410

  
394 411
        now = timezone.now().date()
395 412
        if self.time_range == 'current-year':
396 413
            params['start'] = date(year=now.year, month=1, day=1)
......
440 457
            params['time_interval'] = 'day'
441 458
        return params
442 459

  
460
    def evaluate_filter_value(self, value):
461
        if isinstance(value, list) or not value.startswith('variable:'):
462
            return value
463

  
464
        try:
465
            variable = self.page.extra_variables[value.replace('variable:', '')]
466
        except KeyError:
467
            raise MissingVariable
468

  
469
        return Template(variable).render(self.request_context)
470

  
471
    @cached_property
472
    def request_context(self):
473
        if hasattr(self, '_context'):
474
            return Context(self._context)
475

  
476
        if not hasattr(self, '_request'):
477
            raise MissingRequest
478

  
479
        return RequestContext(self._request, self._request.extra_context)
480

  
443 481
    def parse_response(self, response, chart):
444 482
        # normalize axis to have a fake axis when there are no dimensions and
445 483
        # always a x axis when there is a single dimension.
combo/apps/dataviz/templates/combo/chartngcell.html
9 9
<script>
10 10
$(function() {
11 11
  var last_width = 1;
12
  var extra_context = $('#chart-{{cell.id}}').parents('.cell').data('extra-context');
13
  var chart_filters_form = $('#chart-filters');
12 14
  $(window).on('load resize gadjo:sidepage-toggled combo:resize-graphs', function() {
13 15
    var chart_cell = $('#chart-{{cell.id}}').parent();
14 16
    var new_width = Math.floor($(chart_cell).width());
15 17
    var ratio = new_width / last_width;
16
    var filter_params = $('#chart-filters').serialize();
18
    var qs = '?width=' + new_width
19
    if(chart_filters_form)
20
        qs += '&' + chart_filters_form.serialize()
21
    if(extra_context)
22
        qs += '&ctx=' + extra_context
17 23
    if (ratio > 1.2 || ratio < 0.8) {
18
      $('#chart-{{cell.id}}').attr('src',
19
            "{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + new_width + '&' + filter_params);
24
      $('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + qs);
20 25
      last_width = new_width;
21 26
    }
22 27
  }).trigger('combo:resize-graphs');
23 28
  $(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);
29
    var qs = '?width=' + last_width
30
    if(chart_filters_form)
31
        qs += '&' + chart_filters_form.serialize()
32
    if(extra_context)
33
        qs += '&ctx=' + extra_context
34
    $('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + qs);
27 35
  });
28 36
});
29 37
</script>
combo/apps/dataviz/views.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
from django.core import signing
17 18
from django.core.exceptions import PermissionDenied
18
from django.http import Http404, HttpResponse
19
from django.http import Http404, HttpResponse, HttpResponseBadRequest
19 20
from django.shortcuts import render
21
from django.template import TemplateSyntaxError, VariableDoesNotExist
20 22
from django.utils.translation import ugettext_lazy as _
21 23
from django.views.generic import DetailView
22 24
from requests.exceptions import HTTPError
......
24 26
from combo.utils import get_templated_url, requests
25 27

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

  
29 31

  
30 32
def ajax_gauge_count(request, *args, **kwargs):
......
54 56
        if not form.is_valid():
55 57
            return self.svg_error(_('Wrong parameters.'))
56 58

  
59
        request.extra_context = {}
60
        if request.GET.get('ctx'):
61
            try:
62
                request.extra_context = signing.loads(request.GET['ctx'])
63
            except signing.BadSignature:
64
                return HttpResponseBadRequest('bad signature')
65

  
66
        form.instance._request = request
57 67
        try:
58 68
            chart = form.instance.get_chart(
59 69
                width=int(request.GET['width']) if request.GET.get('width') else None,
......
61 71
            )
62 72
        except UnsupportedDataSet:
63 73
            return self.svg_error(_('Unsupported dataset.'))
74
        except MissingVariable:
75
            return self.svg_error(_('Page variable not found.'))
76
        except TemplateSyntaxError:
77
            return self.svg_error(_('Syntax error in page variable.'))
78
        except VariableDoesNotExist:
79
            return self.svg_error(_('Cannot evaluate page variable.'))
64 80
        except HTTPError as e:
65 81
            if e.response.status_code == 404:
66 82
                return self.svg_error(_('Visualization not found.'))
combo/utils/spooler.py
95 95

  
96 96
@tenantspool
97 97
def refresh_statistics_data(cell_pk):
98
    from combo.apps.dataviz.models import ChartNgCell
98
    from combo.apps.dataviz.models import ChartNgCell, MissingRequest, MissingVariable
99 99

  
100 100
    try:
101 101
        cell = ChartNgCell.objects.get(pk=cell_pk)
102 102
    except ChartNgCell.DoesNotExist:
103 103
        return
104
    cell.get_statistic_data(invalidate_cache=True)
104
    try:
105
        cell.get_statistic_data(invalidate_cache=True)
106
    except (MissingRequest, MissingVariable):
107
        pass
tests/test_dataviz.py
1377 1377
    assert 'time_interval' in resp.text
1378 1378

  
1379 1379

  
1380
@with_httmock(new_api_mock)
1381
def test_chartng_cell_manager_new_api_page_variables(app, admin_user, new_api_statistics):
1382
    page = Page.objects.create(title='One', slug='index')
1383
    cell = ChartNgCell(page=page, order=1, placeholder='content')
1384
    cell.statistic = Statistic.objects.get(slug='one-serie')
1385
    cell.save()
1386

  
1387
    app = login(app)
1388
    resp = app.get('/manage/pages/%s/' % page.id)
1389
    assert '<optgroup label="Page variables">' not in resp.text
1390

  
1391
    page.extra_variables = {'foo': 'bar', 'bar_id': '{{ 40|add:2 }}'}
1392
    page.save()
1393
    resp = app.get('/manage/pages/%s/' % page.id)
1394
    assert '<optgroup label="Page variables">' in resp.text
1395

  
1396
    field_prefix = 'cdataviz_chartngcell-%s-' % cell.id
1397
    assert resp.form[field_prefix + 'ou'].options == [
1398
        ('', True, '---------'),
1399
        ('default', False, 'Default OU'),
1400
        ('other', False, 'Other OU'),
1401
        ('variable:bar_id', False, 'bar_id'),
1402
        ('variable:foo', False, 'foo'),
1403
    ]
1404
    assert resp.form[field_prefix + 'service'].options == [
1405
        ('', False, '---------'),
1406
        ('chrono', True, 'Chrono'),
1407
        ('combo', False, 'Combo'),
1408
        ('variable:bar_id', False, 'bar_id'),
1409
        ('variable:foo', False, 'foo'),
1410
    ]
1411

  
1412
    resp.form[field_prefix + 'ou'] = 'variable:foo'
1413
    resp = resp.form.submit().follow()
1414
    assert resp.form[field_prefix + 'ou'].value == 'variable:foo'
1415
    cell.refresh_from_db()
1416
    assert cell.filter_params['ou'] == 'variable:foo'
1417

  
1418
    del page.extra_variables['foo']
1419
    page.save()
1420
    resp = app.get('/manage/pages/%s/' % page.id)
1421
    assert resp.form[field_prefix + 'ou'].options == [
1422
        ('', False, '---------'),
1423
        ('default', False, 'Default OU'),
1424
        ('other', False, 'Other OU'),
1425
        ('variable:bar_id', False, 'bar_id'),
1426
        ('variable:foo', True, 'foo (unavailable)'),
1427
    ]
1428

  
1429
    # no variables allowed for time_interval
1430
    time_interval_field = resp.form[field_prefix + 'time_interval']
1431
    assert [x[0] for x in time_interval_field.options] == ['day', 'month', 'year', 'week', 'weekday']
1432

  
1433
    # no variables allowed for multiple choice field
1434
    cell.statistic = Statistic.objects.get(slug='filter-multiple')
1435
    cell.save()
1436
    resp = app.get('/manage/pages/%s/' % page.id)
1437

  
1438
    color_field = resp.form[field_prefix + 'color']
1439
    assert [x[0] for x in color_field.options] == ['red', 'green', 'blue']
1440

  
1441

  
1380 1442
@with_httmock(bijoe_mock)
1381 1443
def test_table_cell(app, admin_user, statistics):
1382 1444
    page = Page(title='One', slug='index')
......
1650 1712
    assert 'start=2021-12-01' in request.url and 'end=2022-01-01' in request.url
1651 1713

  
1652 1714

  
1715
@with_httmock(new_api_mock)
1716
def test_chartng_cell_new_api_filter_params_page_variables(app, admin_user, new_api_statistics, nocache):
1717
    Page.objects.create(title='One', slug='index')
1718
    page = Page.objects.create(
1719
        title='One',
1720
        slug='cards',
1721
        sub_slug='card_id',
1722
        extra_variables={
1723
            'foo': 'bar',
1724
            'bar_id': '{{ 40|add:2 }}',
1725
            'syntax_error': '{% for %}',
1726
            'subslug_dependant': '{{ 40|add:card_id }}',
1727
        },
1728
    )
1729
    cell = ChartNgCell(page=page, order=1, placeholder='content')
1730
    cell.statistic = Statistic.objects.get(slug='one-serie')
1731
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:foo'}
1732
    cell.save()
1733

  
1734
    location = '/api/dataviz/graph/%s/' % cell.pk
1735
    app.get(location)
1736
    request = new_api_mock.call['requests'][0]
1737
    assert 'service=chrono' in request.url
1738
    assert 'ou=bar' in request.url
1739

  
1740
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:bar_id'}
1741
    cell.save()
1742

  
1743
    app.get(location)
1744
    request = new_api_mock.call['requests'][1]
1745
    assert 'service=chrono' in request.url
1746
    assert 'ou=42' in request.url
1747

  
1748
    # unknown variable
1749
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:unknown'}
1750
    cell.save()
1751

  
1752
    resp = app.get(location)
1753
    assert len(new_api_mock.call['requests']) == 2
1754
    assert 'Page variable not found.' in resp.text
1755

  
1756
    # variable with invalid syntax
1757
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:syntax_error'}
1758
    cell.save()
1759

  
1760
    resp = app.get(location)
1761
    assert len(new_api_mock.call['requests']) == 2
1762
    assert 'Syntax error in page variable.' in resp.text
1763

  
1764
    # variable with missing context
1765
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:subslug_dependant'}
1766
    cell.save()
1767

  
1768
    resp = app.get(location)
1769
    assert len(new_api_mock.call['requests']) == 2
1770
    assert 'Cannot evaluate page variable.' in resp.text
1771

  
1772
    # simulate call from page view
1773
    app = login(app)
1774
    resp = app.get('/cards/2/')
1775
    ctx = resp.pyquery('.chartngcell').attr('data-extra-context')
1776

  
1777
    app.get(location + '?ctx=%s' % ctx)
1778
    request = new_api_mock.call['requests'][2]
1779
    assert 'service=chrono' in request.url
1780
    assert 'ou=42' in request.url
1781

  
1782
    # reste à tester missing variable
1783
    # et avec display table
1784

  
1785

  
1786
@with_httmock(new_api_mock)
1787
def test_chartng_cell_new_api_filter_params_page_variables_table(new_api_statistics, nocache):
1788
    Page.objects.create(title='One', slug='index')
1789
    page = Page.objects.create(
1790
        title='One',
1791
        slug='cards',
1792
        sub_slug='card_id',
1793
        extra_variables={
1794
            'foo': 'bar',
1795
            'syntax_error': '{% for %}',
1796
            'subslug_dependant': '{{ 40|add:card_id }}',
1797
        },
1798
    )
1799
    cell = ChartNgCell(page=page, order=1, placeholder='content', chart_type='table')
1800
    cell.statistic = Statistic.objects.get(slug='one-serie')
1801
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:foo'}
1802
    cell.save()
1803

  
1804
    cell.render({**page.extra_variables, 'synchronous': True})
1805
    request = new_api_mock.call['requests'][0]
1806
    assert 'service=chrono' in request.url
1807
    assert 'ou=bar' in request.url
1808

  
1809
    # unknown variable
1810
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:unknown'}
1811
    cell.save()
1812

  
1813
    content = cell.render({'synchronous': True})
1814
    assert len(new_api_mock.call['requests']) == 1
1815
    assert 'Page variable not found.' in content
1816

  
1817
    # variable with invalid syntax
1818
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:syntax_error'}
1819
    cell.save()
1820

  
1821
    content = cell.render({**page.extra_variables, 'synchronous': True})
1822
    assert len(new_api_mock.call['requests']) == 1
1823
    assert 'Syntax error in page variable.' in content
1824

  
1825
    # variable with missing context
1826
    cell.filter_params = {'service': 'chrono', 'ou': 'variable:subslug_dependant'}
1827
    cell.save()
1828

  
1829
    content = cell.render({**page.extra_variables, 'synchronous': True})
1830
    assert len(new_api_mock.call['requests']) == 1
1831
    assert 'Cannot evaluate page variable.' in content
1832

  
1833

  
1653 1834
def test_dataviz_check_validity(nocache):
1654 1835
    page = Page.objects.create(title='One', slug='index')
1655 1836
    stat = Statistic.objects.create(url='https://stat.com/stats/1/')
......
1962 2143
    refresh_statistics_data(cell.pk)
1963 2144
    assert len(new_api_mock.call['requests']) == 2
1964 2145

  
2146
    # variables cannot be evaluated in spooler
2147
    page.extra_variables = {'test': 'test'}
2148
    page.save()
2149
    cell.filter_params = {'ou': 'variable:test'}
2150
    cell.save()
2151
    refresh_statistics_data(cell.pk)
2152
    assert len(new_api_mock.call['requests']) == 2
2153

  
1965 2154
    ChartNgCell.objects.all().delete()
1966 2155
    refresh_statistics_data(cell.pk)
1967 2156
    assert len(new_api_mock.call['requests']) == 2
1968
-