From 4240b269bfe343d162f3ea002dd7c82e40fd4afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Kervella?= Date: Wed, 18 Oct 2017 01:04:39 +0000 Subject: [PATCH] Refactoring complet du mbf tag + pep8 du tag Le tag se base maintenant sur des classes pour avoir un code plus clair --- machines/views.py | 1 - re2o/templatetags/massive_bootstrap_form.py | 1037 +++++++++++-------- 2 files changed, 584 insertions(+), 454 deletions(-) diff --git a/machines/views.py b/machines/views.py index 6a87a9f8..92ceedfa 100644 --- a/machines/views.py +++ b/machines/views.py @@ -54,7 +54,6 @@ from .forms import EditOuverturePortListForm, EditOuverturePortConfigForm from .models import IpType, Machine, Interface, IpList, MachineType, Extension, Mx, Ns, Domain, Service, Service_link, Vlan, Nas, Text, OuverturePortList, OuverturePort from users.models import User from preferences.models import GeneralOption, OptionalMachine -from re2o.templatetags.massive_bootstrap_form import hidden_id, input_id from re2o.utils import all_active_assigned_interfaces, all_has_access from re2o.views import form diff --git a/re2o/templatetags/massive_bootstrap_form.py b/re2o/templatetags/massive_bootstrap_form.py index ae4d5cdc..a543d26e 100644 --- a/re2o/templatetags/massive_bootstrap_form.py +++ b/re2o/templatetags/massive_bootstrap_form.py @@ -2,28 +2,35 @@ # Re2o est un logiciel d'administration développé initiallement au rezometz. Il # se veut agnostique au réseau considéré, de manière à être installable en # quelques clics. -# +# # Copyright © 2017 Maël Kervella -# +# # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. -# +# # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -# +# # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" Templatetag used to render massive django form selects into bootstrap +forms that can still be manipulating even if there is multiple tens of +thousands of elements in the select. It's made possible using JS libaries +Twitter Typeahead and Splitree's Tokenfield. +See docstring of massive_bootstrap_form for a detailed explaantion on how +to use this templatetag. +""" + from django import template from django.utils.safestring import mark_safe from django.forms import TextInput from django.forms.widgets import Select -from bootstrap3.templatetags.bootstrap3 import bootstrap_form from bootstrap3.utils import render_tag from bootstrap3.forms import render_field @@ -178,501 +185,625 @@ def massive_bootstrap_form(form, mbf_fields, *args, **kwargs): {% massive_bootstrap_form form 'ipv4' choices='[...]' %} """ - fields = mbf_fields.split(',') - param = kwargs.pop('mbf_param', {}) - exclude = param.get('exclude', '').split(',') - choices = param.get('choices', {}) - engine = param.get('engine', {}) - match_func = param.get('match_func', {}) - update_on = param.get('update_on', {}) - gen_select = param.get('gen_select', {}) - hidden_fields = [h.name for h in form.hidden_fields()] + mbf_form = MBFForm(form, mbf_fields.split(','), *args, **kwargs) + return mbf_form.render() - html = '' - for f_name, f_value in form.fields.items() : - if not f_name in exclude : - if f_name in fields and not f_name in hidden_fields : - if not isinstance(f_value.widget, Select) : - raise ValueError( - ('Field named {f_name} from {form} is not a Select and' - 'can\'t be rendered with massive_bootstrap_form.' - ).format( - f_name=f_name, - form=form - ) + +class MBFForm(): + """ An object to hold all the information and useful methods needed to + create and render a massive django form into an actual HTML and JS + code able to handle it correctly. + Every field that is not listed is rendered as a normal bootstrap_field. + """ + + + def __init__(self, form, mbf_fields, *args, **kwargs): + # The django form object + self.form = form + # The fields on which to use JS + self.fields = mbf_fields + + # Other bootstrap_form arguments to render the fields + self.args = args + self.kwargs = kwargs + + # Fields to exclude form the form rendering + self.exclude = self.kwargs.get('exclude', '').split(',') + + # All the mbf parameters specified byt the user + param = kwargs.pop('mbf_param', {}) + self.choices = param.get('choices', {}) + self.engine = param.get('engine', {}) + self.match_func = param.get('match_func', {}) + self.update_on = param.get('update_on', {}) + self.gen_select = param.get('gen_select', {}) + self.hidden_fields = [h.name for h in self.form.hidden_fields()] + + # HTML code to insert inside a template + self.html = "" + + + def render(self): + """ HTML code for the fully rendered form with all the necessary form + """ + for name, field in self.form.fields.items(): + if not name in self.exclude: + + if name in self.fields and not name in self.hidden_fields: + mbf_field = MBFField( + name, + field, + field.get_bound_field(self.form, name), + self.choices.get(name, None), + self.engine.get(name, None), + self.match_func.get(name, None), + self.update_on.get(name, None), + self.update_on.get(name, True), + *self.args, + **self.kwargs + ) + self.html += mbf_field.render() + + else: + self.html += render_field( + field.get_bound_field(self.form, name), + *self.args, + **self.kwargs ) - multiple = f_value.widget.allow_multiple_selected - f_bound = f_value.get_bound_field( form, f_name ) + return mark_safe(self.html) - if gen_select.get(f_name, True) : - html += render_field( - f_bound, - *args, - **kwargs - ) - f_value.widget = TextInput( - attrs = { - 'name': 'mbf_'+f_name, - 'placeholder': f_value.empty_label - } + + + +class MBFField(): + """ An object to hold all the information and useful methods needed to + create and render a massive django form field into an actual HTML and JS + code able to handle it correctly. + Twitter Typeahead is used for the display and the matching of queries and + in case of a MultipleSelect, Sliptree's Tokenfield is also used to manage + multiple values. + A div with only non visible elements is created after the div containing + the displayed input. It's used to store the actual data that will be sent + to the server """ + + + def __init__(self, name_, field_, bound_, choices_, engine_, match_func_, + update_on_, gen_select_, *args_, **kwargs_): + + # Verify this field is a Select (or MultipleSelect) (only supported) + if not isinstance(field_.widget, Select): + raise ValueError( + ('Field named {f_name} is not a Select and' + 'can\'t be rendered with massive_bootstrap_form.' + ).format( + f_name=name_ ) - replace_input = render_field( - f_value.get_bound_field( form, f_name ), - *args, - **kwargs + ) + + # Name of the field + self.name = name_ + # Django field object + self.field = field_ + # Bound Django field associated with field + self.bound = bound_ + + # Id for the main visible input + self.input_id = self.bound.auto_id + # Id for a hidden input used to store the value + self.hidden_id = self.input_id + '_hidden' + # Id for another div containing hidden inputs and script + self.div2_id = self.input_id + '_div' + + # Should the standard select should be generated + self.gen_select = gen_select_ + # Is it select with multiple values possible (use of tokenfield) + self.multiple = self.field.widget.allow_multiple_selected + # JS for the choices variable (user specified or default) + self.choices = choices_ or self.default_choices() + # JS for the engine variable (typeahead) (user specified or default) + self.engine = engine_ or self.default_engine() + # JS for the matching function (typeahead) (user specified or default) + self.match_func = match_func_ or self.default_match_func() + # JS for the datasets variable (typeahead) (user specified or default) + self.datasets = self.default_datasets() + # Ids of other fields to bind a reset/reload with when changed + self.update_on = update_on_ or [] + + # Whole HTML code to insert in the template + self.html = "" + # JS code in the script tag + self.js_script = "" + # Input tag to display instead of select + self.replace_input = None + + # Other bootstrap_form arguments to render the fields + self.args = args_ + self.kwargs = kwargs_ + + + def default_choices(self): + """ JS code of the variable choices_ """ + + if self.gen_select: + return ( + 'function plop(o) {{' + 'var c = [];' + 'for( let i=0 ; i """ + return ( + 'new Bloodhound({{' + ' datumTokenizer: Bloodhound.tokenizers.obj.whitespace("value"),' + ' queryTokenizer: Bloodhound.tokenizers.whitespace,' + ' local: choices_{name},' + ' identify: function(obj) {{ return obj.key; }}' + '}})' + ).format( + name=self.name + ) + + + def default_datasets(self): + """ Default JS script of the datasets to use with typeahead """ + return ( + '{{' + ' hint: true,' + ' highlight: true,' + ' minLength: 0' + '}},' + '{{' + ' display: "value",' + ' name: "{name}",' + ' source: {match_func}' + '}}' + ).format( + name=self.name, + match_func=self.match_func + ) + + + def default_match_func(self): + """ Default JS code of the matching function to use with typeahed """ + return ( + 'function ( q, sync ) {{' + ' if ( q === "" ) {{' + ' var first = choices_{name}.slice( 0, 5 ).map(' + ' function ( obj ) {{ return obj.key; }}' + ' );' + ' sync( engine_{name}.get( first ) );' + ' }} else {{' + ' engine_{name}.search( q, sync );' + ' }}' + '}}' + ).format( + name=self.name + ) + + + def render(self): + """ HTML code for the fully rendered field """ + self.gen_displayed_div() + self.gen_hidden_div() + return mark_safe(self.html) + + + def gen_displayed_div(self): + """ Generate HTML code for the div that contains displayed tags """ + if self.gen_select: + self.html += render_field( + self.bound, + *self.args, + **self.kwargs + ) + + self.field.widget = TextInput( + attrs={ + 'name': 'mbf_'+self.name, + 'placeholder': self.field.empty_label + } + ) + self.replace_input = render_field( + self.bound, + *self.args, + **self.kwargs + ) + + if not self.gen_select: + self.html += self.replace_input + + + def gen_hidden_div(self): + """ Generate HTML code for the div that contains hidden tags """ + self.gen_full_js() + + content = self.js_script + if not self.multiple and not self.gen_select: + content += self.hidden_input() + + self.html += render_tag( + 'div', + content=content, + attrs={'id': self.div2_id} + ) + + + def hidden_input(self): + """ HTML for the hidden input element """ + return render_tag( + 'input', + attrs={ + 'id': self.hidden_id, + 'name': self.bound.html_name, + 'type': 'hidden', + 'value': self.bound.value() or "" + } + ) + + + def gen_full_js(self): + """ Generate the full script tag containing the JS code """ + self.create_js() + self.fill_js() + self.get_script() + + + def create_js(self): + """ Generate a template for the whole script to use depending on + gen_select and multiple """ + if self.gen_select: + if self.multiple: + self.js_script = ( + '$( "#{input_id}" ).ready( function() {{' + ' var choices_{f_name} = {choices};' + ' {del_select}' + ' var engine_{f_name};' + ' var setup_{f_name} = function() {{' + ' engine_{f_name} = {engine};' + ' $( "#{input_id}" ).tokenfield( "destroy" );' + ' $( "#{input_id}" ).tokenfield({{typeahead: [ {datasets} ] }});' + ' }};' + ' $( "#{input_id}" ).bind( "tokenfield:createtoken", {tok_create} );' + ' $( "#{input_id}" ).bind( "tokenfield:edittoken", {tok_edit} );' + ' $( "#{input_id}" ).bind( "tokenfield:removetoken", {tok_remove} );' + ' {tok_updates}' + ' setup_{f_name}();' + ' {tok_init_input}' + '}} );' ) - if not multiple and not gen_select.get(f_name, True) : - content += hidden_tag( f_bound, f_name ) - - html += render_tag( - 'div', - content = content, - attrs = { 'id': custom_div_id( f_bound ) } - ) - else: - html += render_field( - f_value.get_bound_field( form, f_name ), - *args, - **kwargs + self.js_script = ( + '$( "#{input_id}" ).ready( function() {{' + ' var choices_{f_name} = {choices};' + ' {del_select}' + ' {gen_hidden}' + ' var engine_{f_name};' + ' var setup_{f_name} = function() {{' + ' engine_{f_name} = {engine};' + ' $( "#{input_id}" ).typeahead( "destroy" );' + ' $( "#{input_id}" ).typeahead( {datasets} );' + ' }};' + ' $( "#{input_id}" ).bind( "typeahead:select", {typ_select} );' + ' $( "#{input_id}" ).bind( "typeahead:change", {typ_change} );' + ' {typ_updates}' + ' setup_{f_name}();' + ' {typ_init_input}' + '}} );' ) - - return mark_safe( html ) - -def input_id( f_bound ) : - """ The id of the HTML input element """ - return f_bound.auto_id - -def hidden_id( f_bound ): - """ The id of the HTML hidden input element """ - return input_id( f_bound ) + '_hidden' - -def custom_div_id( f_bound ): - """ The id of the HTML div element containing values and script """ - return input_id( f_bound ) + '_div' - -def hidden_tag( f_bound, f_name ): - """ The HTML hidden input element """ - return render_tag( - 'input', - attrs={ - 'id': hidden_id( f_bound ), - 'name': f_bound.html_name, - 'type': 'hidden', - 'value': f_bound.value() or "" - } - ) - -def mbf_js( f_name, f_value, f_bound, multiple, replace_input, - choices_, engine_, match_func_, update_on_, gen_select_ ) : - """ The whole script to use """ - - gen_select = gen_select_.get( f_name, True ) - - choices = ( mark_safe( choices_[f_name] ) if f_name in choices_.keys() - else default_choices( f_value, f_bound, gen_select ) ) - - engine = ( mark_safe( engine_[f_name] ) if f_name in engine_.keys() - else default_engine ( f_name ) ) - - match_func = ( mark_safe( match_func_[f_name] ) - if f_name in match_func_.keys() else default_match_func( f_name ) ) - - update_on = update_on_[f_name] if f_name in update_on_.keys() else [] - - if gen_select : - if multiple : - js_content = ( - '$( "#{input_id}" ).ready( function() {{' + else: + if self.multiple: + self.js_script = ( 'var choices_{f_name} = {choices};' - '{del_select}' 'var engine_{f_name};' 'var setup_{f_name} = function() {{' - 'engine_{f_name} = {engine};' - '$( "#{input_id}" ).tokenfield( "destroy" );' - '$( "#{input_id}" ).tokenfield({{typeahead: [ {datasets} ] }});' + ' engine_{f_name} = {engine};' + ' $( "#{input_id}" ).tokenfield( "destroy" );' + ' $( "#{input_id}" ).tokenfield({{typeahead: [ {datasets} ] }});' '}};' '$( "#{input_id}" ).bind( "tokenfield:createtoken", {tok_create} );' '$( "#{input_id}" ).bind( "tokenfield:edittoken", {tok_edit} );' '$( "#{input_id}" ).bind( "tokenfield:removetoken", {tok_remove} );' '{tok_updates}' - 'setup_{f_name}();' - '{tok_init_input}' - '}} );' - ) - else : - js_content = ( - '$( "#{input_id}" ).ready( function() {{' - 'var choices_{f_name} = {choices};' - '{del_select}' - '{gen_hidden}' + '$( "#{input_id}" ).ready( function() {{' + ' setup_{f_name}();' + ' {tok_init_input}' + '}} );' + ) + else: + self.js_script = ( + 'var choices_{f_name} ={choices};' 'var engine_{f_name};' 'var setup_{f_name} = function() {{' - 'engine_{f_name} = {engine};' - '$( "#{input_id}" ).typeahead( "destroy" );' - '$( "#{input_id}" ).typeahead( {datasets} );' + ' engine_{f_name} = {engine};' + ' $( "#{input_id}" ).typeahead( "destroy" );' + ' $( "#{input_id}" ).typeahead( {datasets} );' '}};' '$( "#{input_id}" ).bind( "typeahead:select", {typ_select} );' '$( "#{input_id}" ).bind( "typeahead:change", {typ_change} );' '{typ_updates}' - 'setup_{f_name}();' - '{typ_init_input}' - '}} );' - ) - else : - if multiple : - js_content = ( - 'var choices_{f_name} = {choices};' - 'var engine_{f_name};' - 'var setup_{f_name} = function() {{' - 'engine_{f_name} = {engine};' - '$( "#{input_id}" ).tokenfield( "destroy" );' - '$( "#{input_id}" ).tokenfield({{typeahead: [ {datasets} ] }});' - '}};' - '$( "#{input_id}" ).bind( "tokenfield:createtoken", {tok_create} );' - '$( "#{input_id}" ).bind( "tokenfield:edittoken", {tok_edit} );' - '$( "#{input_id}" ).bind( "tokenfield:removetoken", {tok_remove} );' - '{tok_updates}' - '$( "#{input_id}" ).ready( function() {{' - 'setup_{f_name}();' - '{tok_init_input}' - '}} );' - ) - else : - js_content = ( - 'var choices_{f_name} = {choices};' - 'var engine_{f_name};' - 'var setup_{f_name} = function() {{' - 'engine_{f_name} = {engine};' - '$( "#{input_id}" ).typeahead( "destroy" );' - '$( "#{input_id}" ).typeahead( {datasets} );' - '}};' - '$( "#{input_id}" ).bind( "typeahead:select", {typ_select} );' - '$( "#{input_id}" ).bind( "typeahead:change", {typ_change} );' - '{typ_updates}' - '$( "#{input_id}" ).ready( function() {{' - 'setup_{f_name}();' - '{typ_init_input}' - '}} );' - ) + '$( "#{input_id}" ).ready( function() {{' + ' setup_{f_name}();' + ' {typ_init_input}' + '}} );' + ) - js_content = js_content.format( - f_name = f_name, - choices = choices, - del_select = del_select( f_bound, replace_input ), - gen_hidden = gen_hidden( f_bound ), - engine = engine, - input_id = input_id( f_bound ), - datasets = default_datasets( f_name, match_func ), - typ_select = typeahead_select( f_bound ), - typ_change = typeahead_change( f_bound ), - tok_create = tokenfield_create( f_name, f_bound ), - tok_edit = tokenfield_edit( f_name, f_bound ), - tok_remove = tokenfield_remove( f_name, f_bound ), - typ_updates = ''.join( [ ( - '$( "#{u_id}" ).change( function() {{' - 'setup_{f_name}();' - '{reset_input}' - '}} );' - ).format( - u_id = u_id, - reset_input = typeahead_reset_input( f_bound ), - f_name = f_name - ) for u_id in update_on ] - ), - tok_updates = ''.join( [ ( - '$( "#{u_id}" ).change( function() {{' - 'setup_{f_name}();' - '{reset_input}' - '}} );' - ).format( - u_id = u_id, - reset_input = tokenfield_reset_input( f_bound ), - f_name = f_name - ) for u_id in update_on ] - ), - tok_init_input = tokenfield_init_input( f_name, f_bound ), - typ_init_input = typeahead_init_input( f_name, f_bound ), - ) - return render_tag( 'script', content=mark_safe( js_content ) ) - -def typeahead_init_input( f_name, f_bound ) : - """ The JS script to init the fields values """ - init_key = f_bound.value() or '""' - return ( - '$( "#{input_id}" ).typeahead("val", {init_val});' - '$( "#{hidden_id}" ).val( {init_key} );' - ).format( - input_id = input_id( f_bound ), - init_val = '""' if init_key == '""' else - 'engine_{f_name}.get( {init_key} )[0].value'.format( - f_name = f_name, - init_key = init_key - ), - init_key = init_key, - hidden_id = hidden_id( f_bound ) + def fill_js(self): + """ Fill the template with the correct values """ + self.js_script = self.js_script.format( + f_name=self.name, + choices=self.choices, + del_select=self.del_select(), + gen_hidden=self.gen_hidden(), + engine=self.engine, + input_id=self.input_id, + datasets=self.datasets, + typ_select=self.typeahead_select(), + typ_change=self.typeahead_change(), + tok_create=self.tokenfield_create(), + tok_edit=self.tokenfield_edit(), + tok_remove=self.tokenfield_remove(), + typ_updates=self.typeahead_updates(), + tok_updates=self.tokenfield_updates(), + tok_init_input=self.tokenfield_init_input(), + typ_init_input=self.typeahead_init_input() ) -def typeahead_reset_input( f_bound ) : - """ The JS script to reset the fields values """ - return ( - '$( "#{input_id}" ).typeahead("val", "");' - '$( "#{hidden_id}" ).val( "" );' + + def get_script(self): + """ Insert the JS code inside a script tag """ + self.js_script = render_tag('script', content=mark_safe(self.js_script)) + + + def del_select(self): + """ JS code to delete the select if it has been generated and replace + it with an input. """ + return ( + 'var p = $("#{select_id}").parent()[0];' + 'var new_input = `{replace_input}`;' + 'p.innerHTML = new_input;' ).format( - input_id = input_id( f_bound ), - hidden_id = hidden_id( f_bound ) + select_id=self.input_id, + replace_input=self.replace_input ) -def tokenfield_init_input( f_name, f_bound ) : - """ The JS script to init the fields values """ - init_key = f_bound.value() or '""' - return ( - '$( "#{input_id}" ).tokenfield("setTokens", {init_val});' + + def gen_hidden(self): + """ JS code to add a hidden tag to store the value. """ + return ( + 'var d = $("#{div2_id}")[0];' + 'var i = document.createElement("input");' + 'i.id = "{hidden_id}";' + 'i.name = "{html_name}";' + 'i.value = "";' + 'i.type = "hidden";' + 'd.appendChild(i);' ).format( - input_id = input_id( f_bound ), - init_val = '""' if init_key == '""' else ( - 'engine_{f_name}.get( {init_key} ).map(' - 'function(o) {{ return o.value; }}' + div2_id=self.div2_id, + hidden_id=self.hidden_id, + html_name=self.bound.html_name + ) + + + def typeahead_init_input(self): + """ JS code to init the fields values """ + init_key = self.bound.value() or '""' + return ( + '$( "#{input_id}" ).typeahead("val", {init_val});' + '$( "#{hidden_id}" ).val( {init_key} );' + ).format( + input_id=self.input_id, + init_val='""' if init_key == '""' else + 'engine_{name}.get( {init_key} )[0].value'.format( + name=self.name, + init_key=init_key + ), + init_key=init_key, + hidden_id=self.hidden_id + ) + + + def typeahead_reset_input(self): + """ JS code to reset the fields values """ + return ( + '$( "#{input_id}" ).typeahead("val", "");' + '$( "#{hidden_id}" ).val( "" );' + ).format( + input_id=self.input_id, + hidden_id=self.hidden_id + ) + + + def typeahead_select(self): + """ JS code to create the function triggered when an item is selected + through typeahead """ + return ( + 'function(evt, item) {{' + ' $( "#{hidden_id}" ).val( item.key );' + ' $( "#{hidden_id}" ).change();' + ' return item;' + '}}' + ).format( + hidden_id=self.hidden_id + ) + + + def typeahead_change(self): + """ JS code of the function triggered when an item is changed (i.e. + looses focus and value has changed since the moment it gained focus ) + """ + return ( + 'function(evt) {{' + ' if ( $( "#{input_id}" ).typeahead( "val" ) === "" ) {{' + ' $( "#{hidden_id}" ).val( "" );' + ' $( "#{hidden_id}" ).change();' + ' }}' + '}}' + ).format( + input_id=self.input_id, + hidden_id=self.hidden_id + ) + + + def typeahead_updates(self): + """ JS code for binding external fields changes with a reset """ + reset_input = self.typeahead_reset_input() + updates = [ + ( + '$( "#{u_id}" ).change( function() {{' + ' setup_{name}();' + ' {reset_input}' + '}} );' + ).format( + u_id=u_id, + name=self.name, + reset_input=reset_input + ) for u_id in self.update_on] + return ''.join(updates) + + + def tokenfield_init_input(self): + """ JS code to init the fields values """ + init_key = self.bound.value() or '""' + return ( + '$( "#{input_id}" ).tokenfield("setTokens", {init_val});' + ).format( + input_id=self.input_id, + init_val='""' if init_key == '""' else ( + 'engine_{name}.get( {init_key} ).map(' + ' function(o) {{ return o.value; }}' ')').format( - f_name = f_name, - init_key = init_key - ), - init_key = init_key, - ) - -def tokenfield_reset_input( f_bound ) : - """ The JS script to reset the fields values """ - return ( - '$( "#{input_id}" ).tokenfield("setTokens", "");' - ).format( - input_id = input_id( f_bound ), - ) - -def default_choices( f_value, f_bound, gen_select ) : - """ The JS script creating the variable choices_ """ - if gen_select : - c = ( 'function plop(o) {{' - 'var c = [];' - 'for( let i=0 ; i