From fef3176a46dd86431d048f5460fe3ad88ec54b38 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 18 Mar 2021 14:18:10 +0100 Subject: [PATCH 2/2] manager: load visualization filter choices using ajax (#35569) --- bijoe/visualization/forms.py | 24 ++++++++++- bijoe/visualization/urls.py | 1 + bijoe/visualization/views.py | 59 +++++++++++++++++++++++++++- tests/fixtures/schema1/01_schema.sql | 4 +- tests/settings.py | 1 + tests/test_schema1.py | 57 ++++++++++++++++++++------- tests/utils.py | 10 +++++ 7 files changed, 138 insertions(+), 18 deletions(-) diff --git a/bijoe/visualization/forms.py b/bijoe/visualization/forms.py index c28b86d..57e651c 100644 --- a/bijoe/visualization/forms.py +++ b/bijoe/visualization/forms.py @@ -24,7 +24,7 @@ from django.utils.safestring import mark_safe from django.forms import ModelForm, TextInput, NullBooleanField from django.conf import settings -from django_select2.forms import Select2MultipleWidget +from django_select2.forms import HeavySelect2MultipleWidget from . import models @@ -142,6 +142,21 @@ class DateRangeField(forms.MultiValueField): return {'start': None, 'end': None} +class Select2ChoicesWidget(HeavySelect2MultipleWidget): + max_results = 10 + + def __init__(self, *args, **kwargs): + self.warehouse = kwargs.pop('warehouse') + self.cube = kwargs.pop('cube') + self.dimension = kwargs.pop('dimension') + return super().__init__(*args, **kwargs) + + def optgroups(self, name, value, attrs=None): + # Render only selected options + self.choices = [(k, v) for k, v in self.choices if k in value] + return super().optgroups(name, value, attrs=attrs) + + class CubeForm(forms.Form): representation = forms.ChoiceField( label=_(u'Presentation'), @@ -192,7 +207,12 @@ class CubeForm(forms.Form): choices=[(s, label) for v, s, label in members], coerce=coercion_function(members), required=False, - widget=Select2MultipleWidget()()) + widget=Select2ChoicesWidget( + data_view='select2-choices', + warehouse=cube.engine.warehouse.name, + cube=cube.name, + dimension=dimension.name + )) # group by self.base_fields['drilldown_x'] = forms.ChoiceField( diff --git a/bijoe/visualization/urls.py b/bijoe/visualization/urls.py index 3d5d368..7187e99 100644 --- a/bijoe/visualization/urls.py +++ b/bijoe/visualization/urls.py @@ -42,4 +42,5 @@ urlpatterns = [ url(r'(?P\d+)/delete/$', views.delete_visualization, name='delete-visualization'), url(r'(?P\d+)/export$', views.export_visualization, name='export-visualization'), url(r'(?P\d+)/save-as/$', views.save_as_visualization, name='save-as-visualization'), + url(r'^select2choices.json$', views.select2_choices, name='select2-choices'), ] diff --git a/bijoe/visualization/views.py b/bijoe/visualization/views.py index 70d4f18..7d24ca6 100644 --- a/bijoe/visualization/views.py +++ b/bijoe/visualization/views.py @@ -20,6 +20,8 @@ import hashlib import json from django.conf import settings +from django.core import signing +from django.core.signing import BadSignature from django.contrib import messages from django.utils.encoding import force_bytes, force_text from django.utils.text import slugify @@ -30,10 +32,11 @@ from django.views.generic.list import MultipleObjectMixin from django.views.generic import DetailView, ListView, View, TemplateView from django.shortcuts import redirect from django.urls import reverse, reverse_lazy -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, JsonResponse from django.core.exceptions import PermissionDenied from django.views.decorators.clickjacking import xframe_options_exempt +from django_select2.cache import cache from rest_framework import generics from rest_framework.response import Response @@ -425,6 +428,59 @@ class VisualizationsExportView(views.AuthorizationMixin, View): return response +class Select2ChoicesView(View): + + def get(self, request, *args, **kwargs): + widget = self.get_widget_or_404() + try: + warehouse = Engine([warehouse for warehouse in get_warehouses() + if warehouse.name == widget.warehouse][0]) + cube = warehouse[widget.cube] + self.dimension = cube.dimensions[widget.dimension] + except IndexError: + raise Http404() + + try: + page_number = int(request.GET.get('page', 1)) - 1 + except ValueError: + raise Http404('Invalid page number.') + + term = request.GET.get('term', '') + choices = self.get_choices(term, page_number, widget.max_results) + + return JsonResponse({ + 'results': [{'text': label, 'id': s} for s, label in choices], + 'more': not(len(choices) < widget.max_results), + }) + + def get_choices(self, term, page_number, max_results): + members = [] + for _id, label in self.dimension.members(): + members.append((_id, str(_id), label)) + members.append((None, '__none__', _('None'))) + + choices = [(s, label) for v, s, label in members if term in label.lower()] + choices = choices[page_number * max_results:(page_number * max_results) + max_results] + return choices + + def get_widget_or_404(self): + field_id = self.request.GET.get('field_id', None) + if not field_id: + raise Http404('No "field_id" provided.') + try: + key = signing.loads(field_id) + except BadSignature: + raise Http404('Invalid "field_id".') + else: + cache_key = '%s%s' % (settings.SELECT2_CACHE_PREFIX, key) + widget_dict = cache.get(cache_key) + if widget_dict is None: + raise Http404('field_id not found') + if widget_dict.pop('url') != self.request.path: + raise Http404('field_id was issued for the view.') + return widget_dict['widget'] + + warehouse = WarehouseView.as_view() cube = CubeView.as_view() cube_iframe = xframe_options_exempt(CubeIframeView.as_view()) @@ -442,6 +498,7 @@ visualization_ods = VisualizationODSView.as_view() visualization_json = VisualizationJSONView.as_view() visualizations_import = VisualizationsImportView.as_view() visualizations_export = VisualizationsExportView.as_view() +select2_choices = Select2ChoicesView.as_view() cube_iframe.mellon_no_passive = True visualization_iframe.mellon_no_passive = True diff --git a/tests/fixtures/schema1/01_schema.sql b/tests/fixtures/schema1/01_schema.sql index 2157d38..d547bb3 100644 --- a/tests/fixtures/schema1/01_schema.sql +++ b/tests/fixtures/schema1/01_schema.sql @@ -45,7 +45,9 @@ INSERT INTO subcategory (category_id, ord, label) VALUES (2, 0, 'subé6'), (3, 1, 'subé7'), (3, 0, 'subé8'), - (3, 0, 'subé9'); + (3, 0, 'subé9'), + (3, 0, 'subé10'), + (3, 0, 'subé11'); INSERT INTO "Facts" (date, datetime, integer, boolean, cnt, innersubcategory_id, leftsubcategory_id, rightsubcategory_id, outersubcategory_id, "String", geo, json) VALUES diff --git a/tests/settings.py b/tests/settings.py index 7e60601..dc36d4c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -3,3 +3,4 @@ BIJOE_INIT_SQL = [ 'SET lc_time = \'fr_FR.UTF-8\'', ] PAGE_LENGTH = 0 +SELECT2_CACHE_PREFIX = 'select2_' diff --git a/tests/test_schema1.py b/tests/test_schema1.py index 07ddf47..de521a0 100644 --- a/tests/test_schema1.py +++ b/tests/test_schema1.py @@ -2,7 +2,7 @@ import json -from utils import login, get_table, get_ods_table, get_ods_document +from utils import login, get_table, get_ods_table, get_ods_document, request_select2 from bijoe.visualization.ods import OFFICE_NS, TABLE_NS from bijoe.visualization.models import Visualization as VisualizationModel @@ -24,9 +24,9 @@ def test_simple(schema1, app, admin): response = form.submit('visualize') assert 'big-msg-info' not in response assert get_table(response) == [ - ['Inner SubCategory', u'sub\xe94', u'sub\xe95', u'sub\xe96', u'sub\xe98', + ['Inner SubCategory', u'sub\xe910', u'sub\xe911', u'sub\xe94', u'sub\xe95', u'sub\xe96', u'sub\xe98', u'sub\xe99', u'sub\xe97', u'sub\xe92', u'sub\xe93', u'sub\xe91'], - ['number of rows', '0', '0', '0', '0', '0', '0', '0', '1', '15'] + ['number of rows', '0', '0', '0', '0', '0', '0', '0', '0', '0', '1', '15'] ] form = response.form form.set('representation', 'table') @@ -77,7 +77,7 @@ def test_boolean_dimension(schema1, app, admin): form.set('drilldown_x', 'boolean') response = form.submit('visualize') assert get_table(response) == [['Boolean', 'Oui', 'Non'], ['number of rows', '8', '9']] - form.set('filter__boolean', [o[0] for o in form.fields['filter__boolean'][0].options if o[2] == 'Oui'][0]) + form['filter__boolean'].force_value([o[0] for o in form.fields['filter__boolean'][0].options if o[2] == 'Oui'][0]) response = form.submit('visualize') assert get_table(response) == [['Boolean', 'Oui', 'Non'], ['number of rows', '8', '0']] @@ -93,7 +93,7 @@ def test_string_dimension(schema1, app, admin): form.set('drilldown_x', 'string') response = form.submit('visualize') assert get_table(response) == [['String', 'a', 'b', 'c', 'Aucun(e)'], ['number of rows', '11', '2', '3', '1']] - form.set('filter__string', ['a', 'b', '__none__']) + form['filter__string'].force_value(['a', 'b', '__none__']) response = form.submit('visualize') assert get_table(response) == [['String', 'a', 'b', 'Aucun(e)'], ['number of rows', '11', '2', '1']] @@ -126,11 +126,11 @@ def test_item_dimension(schema1, app, admin): form.set('drilldown_x', 'outersubcategory') response = form.submit('visualize') assert get_table(response) == [ - ['Outer SubCategory', u'sub\xe94', u'sub\xe95', u'sub\xe96', u'sub\xe98', + ['Outer SubCategory', u'sub\xe910', u'sub\xe911', u'sub\xe94', u'sub\xe95', u'sub\xe96', u'sub\xe98', u'sub\xe99', u'sub\xe97', u'sub\xe92', u'sub\xe93', u'sub\xe91', 'Aucun(e)'], - ['number of rows', '0', '0', '0', '0', '0', '0', '0', '1', '15', '1'] + ['number of rows', '0', '0', '0', '0', '0', '0', '0', '0', '0', '1', '15', '1'] ] - form.set('filter__outersubcategory', ['__none__']) + form['filter__outersubcategory'].force_value(['__none__']) response = form.submit('visualize') assert get_table(response) == [ ['Outer SubCategory', 'Aucun(e)'], @@ -176,7 +176,7 @@ def test_ods(schema1, app, admin): assert get_table(response) == get_ods_table(ods_response)[1:] root = get_ods_document(ods_response) nodes = root.findall('.//{%s}table-cell' % TABLE_NS) - assert len([node for node in nodes if node.attrib['{%s}value-type' % OFFICE_NS] == 'float']) == 11 + assert len([node for node in nodes if node.attrib['{%s}value-type' % OFFICE_NS] == 'float']) == 13 app.reset() # logout assert 'login' in app.get(ods_response.request.url, status=302).location @@ -368,9 +368,10 @@ def test_json_dimensions(schema1, app, admin): ] assert 'filter__a' in form.fields - assert set([o[0] for o in form['filter__a'].options]) == {'x', 'y', 'z', '__none__'} + choices = [o['id'] for o in request_select2(app, response, 'filter__a')['results']] + assert set(choices) == {'x', 'y', 'z', '__none__'} - form.set('filter__a', ['x', 'y']) + form['filter__a'].force_value(['x', 'y']) response = form.submit('visualize') assert get_table(response) == [ ['A', 'x', 'y', 'z'], @@ -395,9 +396,10 @@ def test_json_dimensions_having_percent(schema1, app, admin): ] assert 'filter__a' in form.fields - assert set([o[0] for o in form['filter__a'].options]) == {'x', 'y', 'z', '__none__'} + choices = [o['id'] for o in request_select2(app, response, 'filter__a')['results']] + assert set(choices) == {'x', 'y', 'z', '__none__'} - form.set('filter__a', ['x', 'y']) + form['filter__a'].force_value(['x', 'y']) response = form.submit('visualize') assert get_table(response) == [ ['A', 'x', 'y', 'z'], @@ -419,9 +421,36 @@ def test_sum_integer_measure(schema1, app, admin): ['String', 'a', 'b', 'c', 'Aucun(e)'], ['sum of integer column', '11', '2', '3', '1'], ] - form.set('filter__string', ['a', 'b', '__none__']) + form['filter__string'].force_value(['a', 'b', '__none__']) response = form.submit('visualize') assert get_table(response) == [ ['String', 'a', 'b', 'Aucun(e)'], ['sum of integer column', '11', '2', '1'], ] + + +def test_select2_filter_widget(schema1, app, admin): + login(app, admin) + response = app.get('/') + response = response.click('schema1') + response = response.click('Facts 1') + + resp = request_select2(app, response, 'filter__innersubcategory') + assert len(resp['results']) == 10 + assert resp['more'] is True + + resp = request_select2(app, response, 'filter__innersubcategory', page=2) + assert len(resp['results']) == 2 + assert resp['more'] is False + + resp = request_select2(app, response, 'filter__innersubcategory', term='aucun') + assert len(resp['results']) == 1 + assert resp['more'] is False + + resp = request_select2(app, response, 'filter__innersubcategory', term='é') + assert len(resp['results']) == 10 + assert resp['more'] is True + + resp = request_select2(app, response, 'filter__innersubcategory', term='é', page=2) + assert len(resp['results']) == 1 + assert resp['more'] is False diff --git a/tests/utils.py b/tests/utils.py index 9e75194..77b57fe 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -68,3 +68,13 @@ def get_ods_table(response): for cell_node in row_node.findall('.//{%s}table-cell' % TABLE_NS): row.append(xml_node_text_content(cell_node)) return table + + +def request_select2(app, response, field_id, term='', page=None): + field = response.pyquery('#id_%s' % field_id)[0] + select2_url = field.attrib['data-ajax--url'] + select2_field_id = field.attrib['data-field_id'] + params = {'field_id': select2_field_id, 'term': term} + if page: + params['page'] = page + return app.get(select2_url, params=params).json -- 2.20.1