Pull to refresh

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

Reading time 8 min
Views 13K
Добрый день, хабраюзер.

Предлагаю статью с реализацией поля формы 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
Tags:
Hubs:
+25
Comments 2
Comments Comments 2

Articles