dataviz: allow page variable as filter value (#57616)

This commit is contained in:
Valentin Deniaud 2022-02-10 15:00:08 +01:00
parent 42eebde447
commit 9d7cf579ce
6 changed files with 284 additions and 17 deletions

View File

@ -73,16 +73,28 @@ class ChartFiltersMixin:
if filter_id == 'time_interval':
self.extend_time_interval_choices(choices)
possible_choices = {choice[0] for choice in choices}
for choice in initial if isinstance(initial, list) else [initial]:
if choice and choice not in possible_choices:
choices.append((choice, _('%s (unavailable)') % choice))
required = filter_.get('required', False)
multiple = filter_.get('multiple')
if not required and not multiple:
choices = BLANK_CHOICE_DASH + choices
extra_variables = cell.page.get_extra_variables_keys()
variable_choices = [('variable:' + key, key) for key in extra_variables]
possible_choices = {choice[0] for choice in choices}
for choice in initial if isinstance(initial, list) else [initial]:
if not choice:
continue
if choice.startswith('variable:'):
variable = choice.replace('variable:', '')
if not variable in extra_variables:
variable_choices.append((choice, _('%s (unavailable)') % variable))
elif choice not in possible_choices:
choices.append((choice, _('%s (unavailable)') % choice))
if variable_choices and not multiple and filter_id != 'time_interval':
choices.append((_('Page variables'), variable_choices))
field_class = forms.MultipleChoiceField if multiple else forms.ChoiceField
fields[filter_id] = field_class(
label=filter_['label'], choices=choices, required=required, initial=initial

View File

@ -25,12 +25,13 @@ from dateutil.relativedelta import MO, relativedelta
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.db import models, transaction
from django.template import Context, Template
from django.template import Context, RequestContext, Template, TemplateSyntaxError, VariableDoesNotExist
from django.template.defaultfilters import date as format_date
from django.urls import reverse
from django.utils import timezone
from django.utils.dates import WEEKDAYS
from django.utils.encoding import force_text
from django.utils.functional import cached_property
from django.utils.translation import gettext
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext
@ -45,6 +46,14 @@ class UnsupportedDataSet(Exception):
pass
class MissingRequest(Exception):
pass
class MissingVariable(Exception):
pass
@register_cell_class
class Gauge(CellBase):
title = models.CharField(_('Title'), max_length=150, blank=True, null=True)
@ -299,10 +308,17 @@ class ChartNgCell(CellBase):
def get_cell_extra_context(self, context):
ctx = super().get_cell_extra_context(context)
if self.chart_type == 'table' and self.statistic and self.statistic.url:
self._context = context
try:
chart = self.get_chart(raise_if_not_cached=not (context.get('synchronous')))
except UnsupportedDataSet:
ctx['table'] = '<p>%s</p>' % _('Unsupported dataset.')
except MissingVariable:
ctx['table'] = '<p>%s</p>' % _('Page variable not found.')
except TemplateSyntaxError:
ctx['table'] = '<p>%s</p>' % _('Syntax error in page variable.')
except VariableDoesNotExist:
ctx['table'] = '<p>%s</p>' % _('Cannot evaluate page variable.')
except HTTPError as e:
if e.response.status_code == 404:
ctx['table'] = '<p>%s</p>' % _('Visualization not found.')
@ -391,7 +407,8 @@ class ChartNgCell(CellBase):
return chart
def get_filter_params(self):
params = {k: v for k, v in self.filter_params.items() if v}
params = {k: self.evaluate_filter_value(v) for k, v in self.filter_params.items() if v}
now = timezone.now().date()
if self.time_range == 'current-year':
params['start'] = date(year=now.year, month=1, day=1)
@ -441,6 +458,27 @@ class ChartNgCell(CellBase):
params['time_interval'] = 'day'
return params
def evaluate_filter_value(self, value):
if isinstance(value, list) or not value.startswith('variable:'):
return value
try:
variable = self.page.extra_variables[value.replace('variable:', '')]
except KeyError:
raise MissingVariable
return Template(variable).render(self.request_context)
@cached_property
def request_context(self):
if hasattr(self, '_context'):
return Context(self._context)
if not hasattr(self, '_request'):
raise MissingRequest
return RequestContext(self._request, self._request.extra_context)
def parse_response(self, response, chart):
# normalize axis to have a fake axis when there are no dimensions and
# always a x axis when there is a single dimension.

View File

@ -9,21 +9,29 @@
<script>
$(function() {
var last_width = 1;
var extra_context = $('#chart-{{cell.id}}').parents('.cell').data('extra-context');
var chart_filters_form = $('#chart-filters');
$(window).on('load resize gadjo:sidepage-toggled combo:resize-graphs', function() {
var chart_cell = $('#chart-{{cell.id}}').parent();
var new_width = Math.floor($(chart_cell).width());
var ratio = new_width / last_width;
var filter_params = $('#chart-filters').serialize();
var qs = '?width=' + new_width
if(chart_filters_form)
qs += '&' + chart_filters_form.serialize()
if(extra_context)
qs += '&ctx=' + extra_context
if (ratio > 1.2 || ratio < 0.8) {
$('#chart-{{cell.id}}').attr('src',
"{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + new_width + '&' + filter_params);
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + qs);
last_width = new_width;
}
}).trigger('combo:resize-graphs');
$(window).on('combo:refresh-graphs', function() {
var filter_params = $('#chart-filters').serialize();
$('#chart-{{cell.id}}').attr('src',
"{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + last_width + '&' + filter_params);
var qs = '?width=' + last_width
if(chart_filters_form)
qs += '&' + chart_filters_form.serialize()
if(extra_context)
qs += '&ctx=' + extra_context
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + qs);
});
});
</script>

View File

@ -14,9 +14,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.shortcuts import render
from django.template import TemplateSyntaxError, VariableDoesNotExist
from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView
from requests.exceptions import HTTPError
@ -24,7 +26,7 @@ from requests.exceptions import HTTPError
from combo.utils import get_templated_url, requests
from .forms import ChartNgPartialForm
from .models import ChartNgCell, Gauge, UnsupportedDataSet
from .models import ChartNgCell, Gauge, MissingVariable, UnsupportedDataSet
def ajax_gauge_count(request, *args, **kwargs):
@ -54,6 +56,14 @@ class DatavizGraphView(DetailView):
if not form.is_valid():
return self.svg_error(_('Wrong parameters.'))
request.extra_context = {}
if request.GET.get('ctx'):
try:
request.extra_context = signing.loads(request.GET['ctx'])
except signing.BadSignature:
return HttpResponseBadRequest('bad signature')
form.instance._request = request
try:
chart = form.instance.get_chart(
width=int(request.GET['width']) if request.GET.get('width') else None,
@ -61,6 +71,12 @@ class DatavizGraphView(DetailView):
)
except UnsupportedDataSet:
return self.svg_error(_('Unsupported dataset.'))
except MissingVariable:
return self.svg_error(_('Page variable not found.'))
except TemplateSyntaxError:
return self.svg_error(_('Syntax error in page variable.'))
except VariableDoesNotExist:
return self.svg_error(_('Cannot evaluate page variable.'))
except HTTPError as e:
if e.response.status_code == 404:
return self.svg_error(_('Visualization not found.'))

View File

@ -95,13 +95,17 @@ def refresh_statistics_list():
@tenantspool
def refresh_statistics_data(cell_pk):
from combo.apps.dataviz.models import ChartNgCell
from combo.apps.dataviz.models import ChartNgCell, MissingRequest, MissingVariable
try:
cell = ChartNgCell.objects.get(pk=cell_pk)
except ChartNgCell.DoesNotExist:
return
cell.get_statistic_data(invalidate_cache=True)
try:
cell.get_statistic_data(invalidate_cache=True)
except (MissingRequest, MissingVariable):
return
if cell.statistic.service_slug != 'bijoe':
cell.update_subfilters()

View File

@ -1483,6 +1483,68 @@ def test_chartng_cell_manager_new_api_dynamic_fields(app, admin_user, new_api_st
assert 'time_interval' in resp.text
@with_httmock(new_api_mock)
def test_chartng_cell_manager_new_api_page_variables(app, admin_user, new_api_statistics):
page = Page.objects.create(title='One', slug='index')
cell = ChartNgCell(page=page, order=1, placeholder='content')
cell.statistic = Statistic.objects.get(slug='one-serie')
cell.save()
app = login(app)
resp = app.get('/manage/pages/%s/' % page.id)
assert '<optgroup label="Page variables">' not in resp.text
page.extra_variables = {'foo': 'bar', 'bar_id': '{{ 40|add:2 }}'}
page.save()
resp = app.get('/manage/pages/%s/' % page.id)
assert '<optgroup label="Page variables">' in resp.text
field_prefix = 'cdataviz_chartngcell-%s-' % cell.id
assert resp.form[field_prefix + 'ou'].options == [
('', True, '---------'),
('default', False, 'Default OU'),
('other', False, 'Other OU'),
('variable:bar_id', False, 'bar_id'),
('variable:foo', False, 'foo'),
]
assert resp.form[field_prefix + 'service'].options == [
('', False, '---------'),
('chrono', True, 'Chrono'),
('combo', False, 'Combo'),
('variable:bar_id', False, 'bar_id'),
('variable:foo', False, 'foo'),
]
resp.form[field_prefix + 'ou'] = 'variable:foo'
resp = resp.form.submit().follow()
assert resp.form[field_prefix + 'ou'].value == 'variable:foo'
cell.refresh_from_db()
assert cell.filter_params['ou'] == 'variable:foo'
del page.extra_variables['foo']
page.save()
resp = app.get('/manage/pages/%s/' % page.id)
assert resp.form[field_prefix + 'ou'].options == [
('', False, '---------'),
('default', False, 'Default OU'),
('other', False, 'Other OU'),
('variable:bar_id', False, 'bar_id'),
('variable:foo', True, 'foo (unavailable)'),
]
# no variables allowed for time_interval
time_interval_field = resp.form[field_prefix + 'time_interval']
assert [x[0] for x in time_interval_field.options] == ['day', 'month', 'year', 'week', 'weekday']
# no variables allowed for multiple choice field
cell.statistic = Statistic.objects.get(slug='filter-multiple')
cell.save()
resp = app.get('/manage/pages/%s/' % page.id)
color_field = resp.form[field_prefix + 'color']
assert [x[0] for x in color_field.options] == ['red', 'green', 'blue']
@with_httmock(bijoe_mock)
def test_table_cell(app, admin_user, statistics):
page = Page(title='One', slug='index')
@ -1756,6 +1818,125 @@ def test_chartng_cell_new_api_filter_params_month(new_api_statistics, nocache, f
assert 'start=2021-12-01' in request.url and 'end=2022-01-01' in request.url
@with_httmock(new_api_mock)
def test_chartng_cell_new_api_filter_params_page_variables(app, admin_user, new_api_statistics, nocache):
Page.objects.create(title='One', slug='index')
page = Page.objects.create(
title='One',
slug='cards',
sub_slug='card_id',
extra_variables={
'foo': 'bar',
'bar_id': '{{ 40|add:2 }}',
'syntax_error': '{% for %}',
'subslug_dependant': '{{ 40|add:card_id }}',
},
)
cell = ChartNgCell(page=page, order=1, placeholder='content')
cell.statistic = Statistic.objects.get(slug='one-serie')
cell.filter_params = {'service': 'chrono', 'ou': 'variable:foo'}
cell.save()
location = '/api/dataviz/graph/%s/' % cell.pk
app.get(location)
request = new_api_mock.call['requests'][0]
assert 'service=chrono' in request.url
assert 'ou=bar' in request.url
cell.filter_params = {'service': 'chrono', 'ou': 'variable:bar_id'}
cell.save()
app.get(location)
request = new_api_mock.call['requests'][1]
assert 'service=chrono' in request.url
assert 'ou=42' in request.url
# unknown variable
cell.filter_params = {'service': 'chrono', 'ou': 'variable:unknown'}
cell.save()
resp = app.get(location)
assert len(new_api_mock.call['requests']) == 2
assert 'Page variable not found.' in resp.text
# variable with invalid syntax
cell.filter_params = {'service': 'chrono', 'ou': 'variable:syntax_error'}
cell.save()
resp = app.get(location)
assert len(new_api_mock.call['requests']) == 2
assert 'Syntax error in page variable.' in resp.text
# variable with missing context
cell.filter_params = {'service': 'chrono', 'ou': 'variable:subslug_dependant'}
cell.save()
resp = app.get(location)
assert len(new_api_mock.call['requests']) == 2
assert 'Cannot evaluate page variable.' in resp.text
# simulate call from page view
app = login(app)
resp = app.get('/cards/2/')
ctx = resp.pyquery('.chartngcell').attr('data-extra-context')
app.get(location + '?ctx=%s' % ctx)
request = new_api_mock.call['requests'][2]
assert 'service=chrono' in request.url
assert 'ou=42' in request.url
# reste à tester missing variable
# et avec display table
@with_httmock(new_api_mock)
def test_chartng_cell_new_api_filter_params_page_variables_table(new_api_statistics, nocache):
Page.objects.create(title='One', slug='index')
page = Page.objects.create(
title='One',
slug='cards',
sub_slug='card_id',
extra_variables={
'foo': 'bar',
'syntax_error': '{% for %}',
'subslug_dependant': '{{ 40|add:card_id }}',
},
)
cell = ChartNgCell(page=page, order=1, placeholder='content', chart_type='table')
cell.statistic = Statistic.objects.get(slug='one-serie')
cell.filter_params = {'service': 'chrono', 'ou': 'variable:foo'}
cell.save()
cell.render({**page.extra_variables, 'synchronous': True})
request = new_api_mock.call['requests'][0]
assert 'service=chrono' in request.url
assert 'ou=bar' in request.url
# unknown variable
cell.filter_params = {'service': 'chrono', 'ou': 'variable:unknown'}
cell.save()
content = cell.render({'synchronous': True})
assert len(new_api_mock.call['requests']) == 1
assert 'Page variable not found.' in content
# variable with invalid syntax
cell.filter_params = {'service': 'chrono', 'ou': 'variable:syntax_error'}
cell.save()
content = cell.render({**page.extra_variables, 'synchronous': True})
assert len(new_api_mock.call['requests']) == 1
assert 'Syntax error in page variable.' in content
# variable with missing context
cell.filter_params = {'service': 'chrono', 'ou': 'variable:subslug_dependant'}
cell.save()
content = cell.render({**page.extra_variables, 'synchronous': True})
assert len(new_api_mock.call['requests']) == 1
assert 'Cannot evaluate page variable.' in content
def test_dataviz_check_validity(nocache):
page = Page.objects.create(title='One', slug='index')
stat = Statistic.objects.create(url='https://stat.com/stats/1/')
@ -2124,6 +2305,14 @@ def test_spooler_refresh_statistics_data(new_api_statistics):
refresh_statistics_data(cell.pk)
assert len(new_api_mock.call['requests']) == 2
# variables cannot be evaluated in spooler
page.extra_variables = {'test': 'test'}
page.save()
cell.filter_params = {'ou': 'variable:test'}
cell.save()
refresh_statistics_data(cell.pk)
assert len(new_api_mock.call['requests']) == 2
ChartNgCell.objects.all().delete()
refresh_statistics_data(cell.pk)
assert len(new_api_mock.call['requests']) == 2