0001-dataviz-allow-page-variable-as-filter-value-57616.patch
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 |
- |