...

вторник, 25 февраля 2014 г.

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, в составе которого можно найти это поле.

Можно как подключить приложение, так и скопировать код поля / виджетов / шаблона / скрипта куда угодно.

http://ift.tt/1eqsqXC


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий