如何处理Flask-Admin表单中的有序多对多关系(关联代理)? [英] How to handle ordered many-to-many relationship (association proxy) in Flask-Admin form?

查看:1221
本文介绍了如何处理Flask-Admin表单中的有序多对多关系(关联代理)?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

在声明性模型 Page 调查之间存在多对多的关系,这是由关联代理调解的,因为页面在调查中出现的顺序是重要的是,所以交叉表有一个额外的字段。

  from flask.ext.sqlalchemy import SQLAlchemy 
from sqlalchemy.ext.associationproxy import association_proxy
db = SQLAlchemy()
$ b $ class Page(db.Model):
id = db.Column(db.Integer,primary_key = True)
survey = association_proxy('page_surveys','survey')

class Survey(db.Model):
id = db.Column(db.Integer,primary_key = True)
pages = association_proxy('survey_pages','page')

类SurveyPage(db.Model):
survey_id = db.Column(db.Integer,db.ForeignKey 'survey.id'),primary_key = True)
page_id = db.Column(db.Integer,db.ForeignKey('page.id'),primary_key = True)
ordering = db.Column(db.Integer)#1表示第一页
survey = db.relationship('Survey',backref ='survey_pages')
page = db.relationship('Page', backref ='page_surveys')

现在我想通过Flask-Admin提供一个表单,将页面添加到调查。理想情况下,用户填充表单的顺序决定了 SurveyPage.ordering 的值。这不是诀窍(表单不能被渲染,见最后一个回溯在帖子底部):

  from flask.ext.admin.contrib.sqla从flask.ext.admin导入ModelView 
import Admin
$ b admin = Admin(name ='Project')

class SurveyView(ModelView):
form_columns =('pages',)
def __init__(self,session,** kwargs):
super(SurveyView,self).__ init __(Survey,session, name ='Surveys',** kwargs)

admin.add_view(SurveyView(db.session))

这可以起作用,但是它不会做我想做的事情(它可以让我将SurveyPage对象与调查相关联,但是我将不得不编辑排序
$ b

 类SurveyView(ModelView):
form_columns =('survey_pages', )
#...

据我所知,我可能不得不做一些黑客行为通过重写 sqla.ModelView.fo rm_rules 以及将一些HTML和Javascript插入到继承自 admin / model / create.html 等的模板中。不幸的是,我对Flask-Admin的经验很少,所以想办法解决这个问题需要花费太多的时间。更糟糕的是,文档和示例代码似乎没有涵盖太多的基础知识。帮助将非常感激!


$ b $ p

最后一点从失败的形式追溯:

 在$ find 
中的文件... / python2.7 / site-packages / flask_admin / contrib / sqla / form.py,第416行raise ValueError ('无效的模型属性名称%s。%s'%(model,name))

ValueError:无效的模型属性名称< class'project.models.Survey'> .pages


解决方案

最终答案准备就绪

下面的第一部分是原来的答案,最后附加了完整的答案。

原始答案:存储输入



现在我已经部分解决了我自己的问题。表单域以我想要的方式工作,输入被正确保存到数据库。只有一个方面的缺失:当我打开一个预先存在的调查的编辑窗体,以前添加到调查的页面不显示在表单字段(换句话说,该字段没有预先填充) 。

如果我自己找到最终的解决方案,我将编辑这篇文章。奖金将颁发给谁填补最终的差距首先。请提交一个新的答案,如果你有金色的提示!



令我惊讶的是,我还没有需要使用模板做任何事情。诀窍主要在于避免 Survey.pages Survey.survey_pages 作为表格列,而不是使用不同的名称带有自定义表单字段类型的额外字段。以下是 SurveyView 类的新版本:

 类SurveyView ModelView):
form_columns =('page_list',)
form_extra_fields = {
''page_list'为避免与Survey $实际属性名称冲突而选择的名称b $ b'page_list':Select2MultipleField (
'Pages',
#options必须是(value,label)对的迭代
choices = db.session.query(Page.id,Page.name).all ),
coerce = int),
}

#手动处理表单域中提交的数据
def on_model_change(self,form,model,is_created = False ):
如果不是is_created:
self.session.query(SurveyPage).filter_by(survey = model).delete()
for index,id in enumerate(form.page_list.data) :
SurveyPage(survey = model,page_id = id,ordering = index)
$ b $ def __init__(self,session, ** kwargs):
super(SurveyView,self).__ init __(Survey,session,name ='Surveys',** kwargs)


$ b

Select2MultipleField 的一个变种。flask.ext.admin.form.fields.Select2Field ,我通过简单地复制粘贴和修改代码来进行调整。我感激地使用 flask.ext.admin.form.widgets.Select2Widget 如果您传递正确的构造函数参数已经允许多选择。我已经在这个帖子的底部包含了源代码,以便不破坏文本的流动( edit:这个帖子底部的源代码现在被更新以反映最终答案,而不是使用 Select2Widget


$ b SurveyView class包含数据库查询,这意味着它需要具有实际数据库连接的应用程序上下文。在我的情况下,这是一个问题,因为我的Flask应用程序是作为包含多个模块和子包的包来实现的,我避免了循环依赖。我已经通过在 create_admin 函数中导入包含 SurveyView 类的模块解决了这个问题:

  from ..models导入数据库

def create_admin(app):
admin = Admin(name ='Project ',app = app)
with app.app_context():$ b $ from .views import SurveyView
admin.add_view(SurveyView(db.session))
return admin

为了在编辑表单中预填充字段,我怀疑我需要设置 SurveyView.form_widget_args 并带有'page_list'字段。到目前为止,我还是完全不知道该领域需要做些什么。任何帮助仍然非常感激!

$ h
$ b

另外:预填充select2字段

自动预填充Flask-Admin知道如何处理的表单字段在 flask.ext.admin.model.base.BaseModelView.edit_view 。不幸的是,开箱即不提供任何钩子< on_model_change 来添加自定义预填充操作。作为一个解决方法,我做了一个覆盖 edit_view 的子类来包含这样一个钩子。插入只是一个单一的行,这里显示在上下文中:

$ p $ @expose('/ edit /',methods =(' GET','POST'))
def edit_view(self):
#...
$ b $如果validate_form_on_submit(form):
self.update_model(form如果在request.form中有'_continue_editing',

flash(gettext('Model was successfully saved。'))
return redirect(request.url)
else :
return redirect(return_url)

self.on_form_prefill(form,id)#< - 这是插入

form_opts = FormOpts(widget_args = self .form_widget_args,
form_rules = self._form_edit_rules)

#...

为了不会导致不使用钩子的模型视图的问题,派生类显然也必须提供一个no-op作为def

$ $ p $ def on_form_prefill(self,form,id):
pass

我为这些附加内容创建了一个补丁,并提交了一个 on_form_prefill

然后我可以覆盖 on_form_prefill 方法在我的 SurveyView 类中,如下所示:

  def on_form_prefill(self,form,id):
form.page_list.process_data(
self.session.query(SurveyPage.page_id)
.filter(SurveyPage.survey_id == id)
.order_by(SurveyPage.ordering)
.all()



这就是解决这个问题的部分原因。 ()在变通方法中,我实际上在 flask.ext.admin.contrib.sqla.ModelView的子类中定义了 edit_view code>,因为我需要该类的附加功能,但通常只在 flask.ext.admin.model中定义 edit_view 。然而,在这一点上,我发现了一个新的问题:当输入被完全存储到数据库中时数据库中,页面添加到调查的顺序不被保留。原来这是
更多人使用Select2多个字段的问题






另外:修改顺序



事实证明,Select2不能如果底层表单字段是< select> ,则保持订单。 Select2文档为可排序的多重选择建议< input type =hidden> ,所以我根据 wtforms.widgets定义了一个新的窗口小部件类型。 HiddenInput 并用它来代替:

 来自wtforms导入小部件

class Select2MultipleWidget(widgets.HiddenInput):

(...)

默认情况下,将调用相关字段$ b的_value $ b提供``value =``HTML属性。


input_type ='select2multiple'

def __call __(self,field,* * kwargs):
kwargs.setdefault('data-choices',self.json_choices(field))
kwargs.setdefault('type','hidden')
return super(Select2MultipleWidget, self).__ call __(field,** kwargs)

@staticmethod
def json_choices(field):
objects =('{{id:{},text :{}}'。format(* c)for field.iter_choices())
'$'$'$'$'$'$'$' $ c> data - *
属性是HTML5构造,用于传递元素属性中的任意数据。一旦JQuery解析,这些属性变成 $(element).data()。* 。我在这里使用它来将所有可用页面的列表传输到客户端。

为了确保隐藏的输入字段变得可见并且像Select2字段一样页面加载,我需要扩展 admin / model / edit.html 模板:

  
{%block tail%}
{{super()}}

< script src =// code.jquery.com/ui/1.11.0/jquery-ui.min.js\"></script>
< script>
$('input [data-choices]')。each(function(){
var self = $(this);
self.select2({
data:self .data()。options,
multiple:true,
sortable:true,
width:'220px'
});
self.on(change ,function(){
$(#+ self.id +_val)。html(self.val());
});
self.select2(container ).find(ul.select2-choices)。sortable({
containment:'parent',
start:function(){self.select2(onSortStart);},
update:function(){self.select2(onSortEnd);}
});
});
< / script>
{%endblock%}

作为额外的好处,这使用户能够在这一点上,我的问题终于得到了充分的回答。






Select2MultipleField 的代码。我建议你用 flask.ext.admin.form.fields 运行一个差异来找出差异。

 来自wtforms导入字段
来自flask.ext.admin._compat import text_type,as_unicode
$ b $ class Select2MultipleField(fields.SelectMultipleField):

`Select2< https://github.com/ivaynberg/select2>`_ style选择部件

您必须包含select2.js,form.js和select2样式表to
work。

这是对原始Select2Field的一个稍微改变的派生。

widget = Select2MultipleWidget()

def __init __(self,label = None,validators = None,coerce = text_type,
choices = None,allow_blank = False,blank_text = None,** kwargs):
super(Select2MultipleField,self).__ init__ (
标签,验证器,强制,选择,** kwargs

self.allow_blank = allow_blank
self.blank_ text = blank_text或''

def iter_choices(self):
如果self.allow_blank:
yield(u'__ None',self.blank_text,self.data is [] )

为self.choices中的值,标签:
yield(value,label,self.coerce(value)in self.data)

def process_data自我价值):
如果不是价值:
self.data = []
else:
try:
self.data = []
for v值:
self.data.append(self.coerce(v [0]))
除了(ValueError,TypeError):
self.data = []

def process_formdata(self,valuelist):
如果是valuelist:
如果是valuelist [0] =='__None':
self.data = []
else:
尝试:
self.data = []
在valueelist [0] .split(',')中的值:
self.get(self.coerce(value))
ValueError:
raise ValueError(self.gettext(u'Invalid Choice:could not coerce {}'.format(value)))
$ b $ def pre_validate(self,form):
如果self.allow_blank和self.data是[]:
返回

super(Select2MultipleField,self) .pre_validate(form)

def _value(self):
return','。join(map(str,self.data))


I have a many-to-many relationship between declarative models Page and Survey, which is mediated by association proxies because the order in which pages appear in a survey is important, so the cross-linking table has an additional field.

from flask.ext.sqlalchemy import SQLAlchemy
from sqlalchemy.ext.associationproxy import association_proxy
db = SQLAlchemy()

class Page (db.Model):
    id = db.Column(db.Integer, primary_key = True)
    surveys = association_proxy('page_surveys', 'survey')

class Survey (db.Model):
    id = db.Column(db.Integer, primary_key = True)
    pages = association_proxy('survey_pages', 'page')

class SurveyPage (db.Model):
    survey_id = db.Column(db.Integer, db.ForeignKey('survey.id'), primary_key = True)
    page_id = db.Column(db.Integer, db.ForeignKey('page.id'), primary_key = True)
    ordering = db.Column(db.Integer)  # 1 means "first page"
    survey = db.relationship('Survey', backref = 'survey_pages')
    page = db.relationship('Page', backref = 'page_surveys')

Now I want to offer a form through Flask-Admin that lets the user add pages to a survey. Ideally, the order in which the user fills the pages into the form determines the value of SurveyPage.ordering. This doesn't do the trick (the form cannot be rendered, see last bit of traceback at bottom of post):

from flask.ext.admin.contrib.sqla import ModelView
from flask.ext.admin import Admin

admin = Admin(name='Project')

class SurveyView (ModelView):
    form_columns = ('pages',)
    def __init__ (self, session, **kwargs):
        super(SurveyView, self).__init__(Survey, session, name='Surveys', **kwargs)

admin.add_view(SurveyView(db.session))

This works, but it doesn't do what I want (it lets me associate SurveyPage objects with the survey, but I would have to edit the ordering fields in a separate form):

class SurveyView (ModelView):
    form_columns = ('survey_pages',)
    # ...

I understand that I'll probably have to do some hacking by overriding sqla.ModelView.form_rules as well as inserting some HTML and Javascript into templates that inherit from admin/model/create.html et al. Unfortunately, I have very little experience with Flask-Admin, so figuring out how to tackle that on my own will take too much time. To make things worse, the documentation and example code don't seem to cover much beyond the basics. Help will be much appreciated!


Last bit of traceback from failing form:

File ".../python2.7/site-packages/flask_admin/contrib/sqla/form.py", line 416, in find
raise ValueError('Invalid model property name %s.%s' % (model, name))

ValueError: Invalid model property name <class 'project.models.Survey'>.pages

解决方案

Final answer ready

The first part below is the original answer, the additions that complete the answer are appended at the end.

Original answer: storing the input

By now I have a partial solution to my own question. The form field works in the way I want and the input is correctly saved to the database. There is just one aspect missing: when I open the edit form of a pre-existing Survey, the Pages that were previously added to the Survey do not show up in the form field (in other word, the field is not pre-populated).

I will edit this post if I find the final solution myself. The bounty will be awarded to anyone who fills the final gap first. Please submit a new answer if you have the golden hint!

To my own surprise, I have not needed to do anything with templates yet. The trick lies mostly in avoiding both Survey.pages and Survey.survey_pages as form columns, instead using a different name as an "extra" field with a custom form field type. Here is the new version of the SurveyView class:

class SurveyView (ModelView):
    form_columns = ('page_list',)
    form_extra_fields = {
        # 'page_list' name chosen to avoid name conflict with actual properties of Survey
        'page_list': Select2MultipleField(
            'Pages',
             # choices has to be an iterable of (value, label) pairs
             choices = db.session.query(Page.id, Page.name).all(),
             coerce = int ),
    }

    # handle the data submitted in the form field manually
    def on_model_change (self, form, model, is_created = False):
        if not is_created:
            self.session.query(SurveyPage).filter_by(survey=model).delete()
        for index, id in enumerate(form.page_list.data):
            SurveyPage(survey = model, page_id = id, ordering = index)

    def __init__ (self, session, **kwargs):
        super(SurveyView, self).__init__(Survey, session, name='Surveys', **kwargs)

Select2MultipleField is a variant of flask.ext.admin.form.fields.Select2Field which I adapted by simply copy-pasting and modifying the code. I gratefully use flask.ext.admin.form.widgets.Select2Widget which already allows multiple selection if you pass the right constructor argument. I have included the source code at the bottom of this post in order to not break up the flow of the text (edit: the source code at the bottom of this post is now updated to reflect the final answer, which does not use the Select2Widget anymore).

The body of the SurveyView class contains a database query, which means that it needs the application context with an actual database connection. In my case that is a problem because my Flask application is implemented as a package with multiple modules and sub-packages, and I avoid cyclical dependencies. I have solved it by importing the module that contains the SurveyView class within my create_admin function:

from ..models import db

def create_admin (app):
    admin = Admin(name='Project', app=app)
    with app.app_context():
        from .views import SurveyView
    admin.add_view(SurveyView(db.session))
    return admin

In order to pre-populate the field in the edit form, I suspect I'll need to set SurveyView.form_widget_args with a 'page_list' field. So far it's still completely obscure to me what needs to be in that field. Any help is still very much appreciated!


Addition: pre-populating the select2 field

Automatic pre-filling of form fields that Flask-Admin knows how to handle is done in flask.ext.admin.model.base.BaseModelView.edit_view. Unfortunately, out of the box it doesn't provide any hooks à la on_model_change to add custom pre-filling actions. As a workaround, I made a subclass that overrides edit_view to include such a hook. The insertion is just a single line, here shown in context:

    @expose('/edit/', methods=('GET', 'POST'))
    def edit_view(self):
        # ...

        if validate_form_on_submit(form):
            if self.update_model(form, model):
                if '_continue_editing' in request.form:
                    flash(gettext('Model was successfully saved.'))
                    return redirect(request.url)
                else:
                    return redirect(return_url)

        self.on_form_prefill(form, id)  # <-- this is the insertion

        form_opts = FormOpts(widget_args=self.form_widget_args,
                             form_rules=self._form_edit_rules)

        # ...

In order to not cause problems for model views that don't use the hook, the derived class obviously also has to provide a no-op as the default:

    def on_form_prefill (self, form, id):
        pass

I have created a patch for these additions and submitted a pull request to the Flask-Admin project.

Then I could override the on_form_prefill method in my SurveyView class as follows:

    def on_form_prefill (self, form, id):
        form.page_list.process_data(
            self.session.query(SurveyPage.page_id)
            .filter(SurveyPage.survey_id == id)
            .order_by(SurveyPage.ordering)
            .all()
        )

And that was the solution to this part of this problem. (In the workaround I actually defined the override of edit_view in a subclass of flask.ext.admin.contrib.sqla.ModelView, because I need the added functionality of that class, but edit_view is normally only defined in flask.ext.admin.model.base.BaseModelView.)

However, at this point I discovered a new problem: while the input was completely stored into the database, the order in which the pages were added to the survey was not preserved. This turned out to be an issue more people walk into with Select2 multiple fields.


Addition: fixing the ordering

As it turns out, Select2 cannot preserve order if the underlying form field is a <select>. The Select2 documentation recommends <input type="hidden"> for sortable multiselects, so I defined a new widget type based on wtforms.widgets.HiddenInput and used that instead:

from wtforms import widgets

class Select2MultipleWidget(widgets.HiddenInput):
    """
    (...)

    By default, the `_value()` method will be called upon the associated field
    to provide the ``value=`` HTML attribute.
    """

    input_type = 'select2multiple'

    def __call__(self, field, **kwargs):
        kwargs.setdefault('data-choices', self.json_choices(field))
        kwargs.setdefault('type', 'hidden')
        return super(Select2MultipleWidget, self).__call__(field, **kwargs)

    @staticmethod
    def json_choices (field):
        objects = ('{{"id": {}, "text": "{}"}}'.format(*c) for c in field.iter_choices())
        return '[' + ','.join(objects) + ']'

The data-* attribute is a HTML5 construct to pass arbitrary data in element attributes. Once parsed by JQuery such attributes become $(element).data().*. I here use it to transfer the list of all available pages to the client side.

In order to ensure that the hidden input field becomes visible and behaves like a Select2 field on page load, I needed to extend the admin/model/edit.html template:

{% extends 'admin/model/edit.html' %}

{% block tail %}
    {{ super() }}

    <script src="//code.jquery.com/ui/1.11.0/jquery-ui.min.js"></script>
    <script>
        $('input[data-choices]').each(function ( ) {
            var self = $(this);
            self.select2({
                data:self.data().choices,
                multiple:true,
                sortable:true,
                width:'220px'
            });
            self.on("change", function() {
                $("#" + self.id + "_val").html(self.val());
            });
            self.select2("container").find("ul.select2-choices").sortable({
                containment: 'parent',
                start: function() { self.select2("onSortStart"); },
                update: function() { self.select2("onSortEnd"); }
            });
        });
    </script>
{% endblock %}

As an added bonus, this enables the user to order the widgets that represent the selected pages by drag-and-drop.

At this point, my question is finally fully answered.


The code for Select2MultipleField. I suggest you run a diff with flask.ext.admin.form.fields to find the differences.

from wtforms import fields
from flask.ext.admin._compat import text_type, as_unicode

class Select2MultipleField(fields.SelectMultipleField):
    """
        `Select2 <https://github.com/ivaynberg/select2>`_ styled select widget.

        You must include select2.js, form.js and select2 stylesheet for it to
        work.

        This is a slightly altered derivation of the original Select2Field.
    """
    widget = Select2MultipleWidget()

    def __init__(self, label=None, validators=None, coerce=text_type,
                 choices=None, allow_blank=False, blank_text=None, **kwargs):
        super(Select2MultipleField, self).__init__(
            label, validators, coerce, choices, **kwargs
        )
        self.allow_blank = allow_blank
        self.blank_text = blank_text or ' '

    def iter_choices(self):
        if self.allow_blank:
            yield (u'__None', self.blank_text, self.data is [])

        for value, label in self.choices:
            yield (value, label, self.coerce(value) in self.data)

    def process_data(self, value):
        if not value:
            self.data = []
        else:
            try:
                self.data = []
                for v in value:
                    self.data.append(self.coerce(v[0]))
            except (ValueError, TypeError):
                self.data = []

    def process_formdata(self, valuelist):
        if valuelist:
            if valuelist[0] == '__None':
                self.data = []
            else:
                try:
                    self.data = []
                    for value in valuelist[0].split(','):
                        self.data.append(self.coerce(value))
                except ValueError:
                    raise ValueError(self.gettext(u'Invalid Choice: could not coerce {}'.format(value)))

    def pre_validate(self, form):
        if self.allow_blank and self.data is []:
            return

        super(Select2MultipleField, self).pre_validate(form)

    def _value (self):
        return ','.join(map(str, self.data))

这篇关于如何处理Flask-Admin表单中的有序多对多关系(关联代理)?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆