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 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:                         initial.append(row.attrib)                  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)         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 = []             cleaned_data = formset.cleaned_data             for item in cleaned_data:                  if item:                     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):         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)             formset.is_valid()             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

ссылка на оригинал статьи http://habrahabr.ru/post/213701/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *