Предлагаю статью с реализацией поля форы 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.
-- Разбираем 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}
А дальше «дело техники»
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
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)
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
В этом случае, основной задачей было обеспечение максимальной компактности
{% 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-ы на иконки «крестиков»
и будем подкрашивать строку при пометке ее на удаление
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:
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/
Добавить комментарий