📜 ⬆️ ⬇️

Django forms field - nested table

Good afternoon, habrauzer.

I propose an article with the implementation of a django form field of the “nested table” type, with data storage in XML format.
This will help those interested to better understand the work of the field and the django widget and take a step towards creating any arbitrary field.
If you already know this, then the article may not be interesting for you.


')


For one workflow on django
you need to make support for the input in the document fields of an array of structured elements (table).

After a week of meditation between the options
- Inline formset
- Attached documents (such functionality has already been)
- Custom field / widget with serialization in XML / JSON
formset was selected in XML

The inline formset was rejected due to the significant complexity of the architecture:
- It is necessary to save inline only after its creation (we get into the method of saving the document)
- Need a separate model,
- Model forms

Attached documents also did not fit (do not do the same document structure for each such field)

The idea of ​​a custom field attracted more.
You can shove all the logic in the field / widget and forget about it.
This approach adds a minimum of complexity to the system architecture.

Despite the convenient work with JSON (loads, dumps),
XML was chosen because of the need to generate reports from the database using SQL.
If PostgreSQL supports work with JSON, then at Oracle it appears only from version 12.
When manipulating XML, you can use indexes at the database level through xpath.

Work at the SQL level
--  XML   select t.id, (xpath('/item/@n_phone', nt))[1] as n_phone1, (xpath('/item/@is_primary', nt))[1] as is_primary1, (xpath('/item/@n_phone', nt))[2] as n_phone2, (xpath('/item/@is_primary', nt))[2] as is_primary2 from docflow_document17 t cross join unnest(xpath('/xml/item', t.nested_table::xml)) as nt; --   XML- select t.id from docflow_document17 t where t.id = 2 and ('1231234', 'False') in ( select (xpath('/item/@n_phone', nt_row))[1]::text, (xpath('/item/@is_primary', nt_row))[1]::text from unnest(xpath('/xml/item', t.nested_table::xml)) as nt_row ); 



Initially, a working widget was written right away, which
- Accepted XML in the render method
- Generated and showed formset
- In value_from_datadict, a formset was generated, taking the data parameter, validating, collecting XML and spitting it out
It all worked great and was very simple.
 class XMLTableWidget(widgets_django.Textarea): class Media: js = (settings.STATIC_URL + 'forms_custom/xmltable.js',) def __init__(self, formset_class, attrs=None): super(XMLTableWidget, self).__init__(attrs=None) self._Formset = formset_class def render(self, name, value, attrs=None): initial = [] if value: xml = etree.fromstring(value) for row in xml: initial.append(row.attrib) formset = self._Formset(initial=initial, prefix=name) return render_to_string('forms_custom/xmltable.html', {'formset': formset}) def value_from_datadict(self, data, files, name): u"""    ,    XML  -  formset-  ,    initial- :    formset-  ,       """ formset_data = {k: v for k, v in data.items() if k.startswith(name)} formset = self._Formset(data=formset_data, prefix=name) if formset.is_valid(): from lxml.builder import E xml_items = [] for item in formset.cleaned_data: if item and not item[formset_deletion_field_name]: del item[formset_deletion_field_name] item = {k: unicode(v) for k, v in item.items()} xml_items.append(E.item("", item)) xml = E.xml(*xml_items) return etree.tostring(xml, pretty_print=False) else: initial_value = data.get('initial-%s' % name) if initial_value: return initial_value else: raise Exception(_('Error in table and initial not find')) 



If it were not for one nuance: the impossibility of the normal validation of the formset.
You can, of course, make the formset as soft as possible, catch the XML and check the data at the field or form level.
You can probably store the attribute “is_formset_valid” in the widget and check it from a field of type self.widget.is_formset_valid,
but it somehow became ill.

We need to do a joint work of the field and the widget.
That's what happened in the end.

I decided not to bother re-reading the source code.
Instead, overly commented methods.
The basic idea is to standardize different input parameters:
- XML ​​received during field initialization
- Dictionary with data output from the widget
- Properly prepared construction
convert to a single format of type {"formset": formset, "xml_initial": xml_string}
And then "the matter of technology"

XMLTableField field
 class XMLTableField(fields.Field): widget = widgets_custom.XMLTableWidget hidden_widget = widgets_custom.XMLTableHiddenWidget default_error_messages = {'invalid': _('Error in table')} def __init__(self, formset_class, form_prefix, *args, **kwargs): kwargs['show_hidden_initial'] = True #       super(XMLTableField, self).__init__(*args, **kwargs) self._formset_class = formset_class self._formset_prefix = form_prefix self._procss_widget_data_cache = {} self._prepare_value_cache = {} def prepare_value(self, value): u"""                    unicode,   XML,        initial  ,     POST-,   ,   ,     formset,  xml_initial   hidden_initial .      show_hidden_initial = True     ,    . """ if value is None: return {'xml_initial': value, 'formset': self._formset_class(initial=[], prefix=self._formset_prefix)} elif type(value) == unicode: value_hash = hash(value) if value_hash not in self._prepare_value_cache: initial = [] if value: xml = etree.fromstring(value) for row in xml: #    'False'  False, #    XML    , #     bool attrs = {} for k,v in row.attrib.items(): attrs[k] = False if v == 'False' else v initial.append(attrs) formset = self._formset_class(initial=initial, prefix=self._formset_prefix) self._prepare_value_cache[value_hash] = formset return {'xml_initial': value, 'formset': self._prepare_value_cache[value_hash]} elif type(value) == dict: if 'xml' not in value: formset = self._widget_data_to_formset(value) return {'xml_initial': value['initial'], 'formset': formset} return value def clean(self, value): u"""       ,  ,    formset-,    formset   XML   _formset_to_xml   ValidationError,  formset   """ formset = self._widget_data_to_formset(value, 'clean') return self._formset_to_xml(formset) def _formset_to_xml(self, formset): u"""   XML    .   _has_changed    XML   clean    cleaned_data """ if formset.is_valid(): from lxml.builder import E xml_items = [] for item in formset.cleaned_data: if item and not item.get(formset_deletion_field_name, False): if formset_deletion_field_name in item: del item[formset_deletion_field_name] item = {k: unicode(v) for k, v in item.items()} xml_items.append(E.item("", item)) xml = E.xml(*xml_items) xml_str = etree.tostring(xml, pretty_print=False) return xml_str else: raise ValidationError(self.error_messages['invalid'], code='invalid') def _widget_data_to_formset(self, value, call_from=None): u"""   POST-,   formset-   ,    prepare_value     ,     FormSet-      """ #     -   self.prepare_value formset_hash = hash(frozenset(value.items())) if formset_hash not in self._procss_widget_data_cache: formset = self._formset_class(data=value, prefix=self._formset_prefix) self._procss_widget_data_cache[formset_hash] = formset return formset else: return self._procss_widget_data_cache[formset_hash] def _has_changed(self, initial, data): u"""     .     formset   ,   XML   c  ,   initial-   XML """ formset = self._widget_data_to_formset(data) try: data_value = self._formset_to_xml(formset) except ValidationError: return True return data_value != initial 



XMLTableHiddenWidget
 class XMLTableHiddenWidget(widgets_django.HiddenInput): def render(self, name, value, attrs=None): u"""    xml_initial    render """ value = value['xml_initial'] return super(XMLTableHiddenWidget, self).render(name, value, attrs) 



XMLTableWidget
 class XMLTableWidget(widgets_django.Widget): class Media: js = (settings.STATIC_URL + 'forms_custom/xmltable.js',) def render(self, name, value, attrs=None): u"""    formset,   initial   data   ,     """ formset = value['formset'] return render_to_string('forms_custom/xmltable.html', {'formset': formset}) def value_from_datadict(self, data, files, name): u"""    ,   formset-     clean     ,  initial-,        """ formset_data = {k: v for k, v in data.items() if k.startswith(name)} initial_key = 'initial-%s' % name formset_data['initial'] = data[initial_key] return formset_data 



In this case, the main task was to ensure maximum compactness
XMLTableWidget - template
 {% load base_tags %} {% load base_filters %} {{formset.management_form}} {% if formset.non_field_errors %} <div class='alert alert-danger'> {% for error in form.non_field_errors %} {{ error }}<br/> {% endfor %} </div> {% endif %} <table> {% for form in formset %} {% if forloop.first %} <tr> {% for field in form.visible_fields %} {% if field.name == 'DELETE' %} <td></td> {% else %} <td>{{field.label}}</td> {% endif %} {% endfor %} </tr> {% endif %} <tr> {% for field in form.visible_fields %} {% if field.name == 'DELETE' %} <th > <div class='hide'>{{field}}</div> <a onclick="xmltable_mark_deleted(this, '{{field.auto_id}}')" class="pointer"> <span class="glyphicon glyphicon-remove"></span> </a> </th> {% else %} <td> {{ field|add_widget_css:"form-control" }} {% if field.errors %} <span class="help-block"> {% for error in field.errors %} {{ error }}<br/> {% endfor %} </span> {% endif %} </td> {% endif %} {% endfor %} </tr> {% endfor %} </table> 



Replace standard CheckBoxes with icons of “crosses”
and we will tint the string when marking it for deletion
XMLTableWidget - script
 function xmltable_mark_deleted(p_a, p_checkbox_id) { var chb = $('#' + p_checkbox_id) var row = $(p_a).parents('tr') if(chb.prop('checked')) { chb.removeProp('checked') row.css('background-color', 'white') } else { chb.attr('checked', '1') row.css('background-color', '#f2dede') } } 



Here, in general, that's all.
We can now use this field and get complex tables, validate them as needed
and not much complicated the system code

The user only needs to prepare the FormSet:
XMLTableWidget
 class NestedTableForm(forms.Form): phone_type = forms.ChoiceField(label=u"", choices=[('', '---'), ('1', '.'), ('2', '.')], required=False) n_phone = forms.CharField(label=u"", required=False) is_primary = forms.BooleanField(label=u"", required=False, widget=forms.CheckboxInput(check_test=boolean_check)) nested_table_formset_class = formset_factory(NestedTableForm, can_delete=True) 



and get this field.

I provide a link to the repository with the application for django, in which you can find this field.
You can either connect the application or copy the code of the field / widget / template / script anywhere.
bitbucket.org/dibrovsd/django_forms_custom/src

Source: https://habr.com/ru/post/213701/


All Articles