Django forms поле — вложенная таблица

    Добрый день, хабраюзер.

    Предлагаю статью с реализацией поля формы django типа «вложенная таблица», с хранением данных в XML-формате.
    Это поможет интересующимся лучше разобраться с работой поля и виджета django и сделать шаг к созданию любого произвольного поля.
    Если вы это и так знаете, то для вас статья может быть не интересной.





    Для одного документооборота на django
    нужно сделать поддержку ввода в поля документа массива структурированных элементов (таблицу).

    После недельного раздумья между вариантами
    — Inline formset
    — Вложенные документы (такой функционал уже был)
    — Пользовательски поле / виджет c сериализацией в XML/JSON
    был выбран formset в XML

    Inline formset был отклонен из-за существенного усложнения архитектуры:
    — Нужно сохранять inline только после его создания (влезаем в метод сохранения документа)
    — Нужна отдельная модель,
    — Модельные формы

    Вложенные документы тоже не подошли (не делать же свою структуру документа под каждое такое поле)

    Идея с кастомным полем привлекла больше.
    Можно засунуть всю логику в поле / виджер и забыть о ней.
    Этот подход добавляет минимум сложности к архитектуре системы.

    Несмотря на удобную работу с JSON (loads, dumps),
    был выбран XML из-за необходимости формирования отчетов из базы данных с помощью SQL.
    Если PostgreSQL поддерживает работу с JSON, то у Oracle она появляется только с 12 версии.
    При манипуляции с XML можно использовать индексы на уровне БД через xpath.

    Работа на уровне SQL
    -- Разбираем 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
    );
    



    Изначально сходу был написан работающий виджет, который
    — Принимал XML в метод render
    — Генерировал и показывал formset
    — В value_from_datadict генерировался formset, принимая параметр data, валидировал, собирал XML и выплевывал ее
    Все это отлично работало и было очень простым
    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'))
    



    Если бы не один нюанс: невозможность нормальной валидации formset-а.
    Можно, конечно, сделать formset максимально мягким, ловить XML и проверять данные на уровне поля или формы.
    Можно, наверное в виджете хранить аттрибут «is_formset_valid» и проверять ее из поля типа self.widget.is_formset_valid,
    но от этого как-то нехорошо становилось.

    Нужно делать совместную работу поля и виджета.
    Вот что получилось в итоге.

    Решил не докучать перечитыванием исходного кода.
    Вместо этого, излишне подробно прокомментировал методы.
    Основная идея в том, чтобы стандартизировать разные входные параметры:
    — XML, полученную при инициализации поля
    — Словарь с данными на выходе из виджета
    — Правильно подготовленную конструкцию
    преобразовать в единый формат типа {«formset»: formset, «xml_initial»: xml_string}
    А дальше «дело техники»

    поле XMLTableField
    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
    



    В этом случае, основной задачей было обеспечение максимальной компактности
    XMLTableWidget - шаблон
    {% 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>
    
    



    Заменим стандартные CheckBox-ы на иконки «крестиков»
    и будем подкрашивать строку при пометке ее на удаление
    XMLTableWidget - скрипт
    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')
        }
    }
    



    Вот, в общем-то и все.
    Можем теперь использовать это поле и получать сложные таблицы, валидировать их как нужно
    и не сильно усложнили код системы

    Пользователю нужно только подготовить 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)
    



    и получить это поле.

    Привожу ссылку на репозиторий с приложением для django, в составе которого можно найти это поле.
    Можно как подключить приложение, так и скопировать код поля / виджетов / шаблона / скрипта куда угодно.
    bitbucket.org/dibrovsd/django_forms_custom/src
    Метки:
    • +25
    • 8,4k
    • 2
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 2
    • +1
      Прекрасно!
      П.С.
      Если бы не один ньюанс:

      Правильно — нюанс.
      Пруф
      • +2
        Спасибо, исправил.
        И все-таки, лучше в «личку» пишите, если не сложно.
        Думаю, сообществу в комментах интереснее читать обсуждение, чем разбор грамматических ошибок статьи.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.