如何在Flask-Admin表单中处理有序的多对多关系(关联代理)?

时间:2014-07-17 15:43:19

标签: flask sqlalchemy jinja2 jquery-select2 flask-admin

我在声明模型页面调查之间存在多对多关系,这是由关联代理调解的,因为调查中页面的显示顺序是很重要,因此交叉表还有一个额外的字段。

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')

现在我想通过Flask-Admin提供一个表单,让用户将页面添加到调查中。理想情况下,用户将页面填入表单的顺序决定了SurveyPage.ordering的值。这不起作用(表单无法呈现,请参见帖子底部的最后一点追溯):

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))

这样做有效,但它没有做我想要的(它让我将SurveyPage对象与调查相关联,但我必须以单独的形式编辑ordering字段):

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

我理解我可能不得不通过覆盖sqla.ModelView.form_rules以及将一些HTML和Javascript插入到继承自admin/model/create.html等的模板中来进行一些黑客攻击。不幸的是,我对Flask-Admin的经验很少,所以弄清楚如何自己解决这个问题需要花费太多时间。更糟糕的是,文档和示例代码似乎没有超出基础知识。非常感谢帮助!


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

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

2 个答案:

答案 0 :(得分:9)

准备好最终答案

下面的第一部分是原始答案,最后附加完成答案的补充内容。

原始答案:存储输入

到现在为止,我对自己的问题有了部分解决方案。表单字段以我想要的方式工作,输入正确保存到数据库。缺少一个方面:当我打开预先存在的调查的编辑表单时,之前添加到调查中的页面不会显示在表单字段中(换句话说,该字段未预先填充)

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

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

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)

Select2MultipleFieldflask.ext.admin.form.fields.Select2Field的变体,我通过简单的复制粘贴和修改代码进行了调整。我很感激地使用flask.ext.admin.form.widgets.Select2Widget,如果你传递了正确的构造函数参数,它已经允许多次选择。我已经在这篇文章的底部包含了源代码,以便不分解文本的流程(编辑:此帖子底部的源代码现在已更新,以反映最终答案,而不是再使用Select2Widget )。

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

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

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


添加:预先填充select2字段

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

    @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)

        # ...

为了不对不使用钩子的模型视图造成问题,派生类显然也必须提供no-op作为默认值:

    def on_form_prefill (self, form, id):
        pass

我为这些添加创建了一个补丁,并向Flask-Admin项目提交了pull request

然后我可以覆盖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()
        )

这就解决了这个问题的这一部分。 (在解决方法中,我实际上在edit_view的子类中定义了flask.ext.admin.contrib.sqla.ModelView的覆盖,因为我需要该类的附加功能,但edit_view通常仅在flask.ext.admin.model.base.BaseModelView中定义{1}}。

然而,此时我发现了一个新问题:当输入完全存储到数据库中时,未保留页面添加到调查中的顺序。结果是an issue more people walk into with Select2 multiple fields


增加:修正订单

事实证明,如果基础表单字段是<select>,则Select2无法保留顺序。 Select2文档建议<input type="hidden">用于可排序的多选,因此我根据wtforms.widgets.HiddenInput定义了一个新的窗口小部件类型并改为使用它:

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) + ']'

data-*属性是一个HTML5结构,用于传递元素属性中的任意数据。一旦被JQuery解析,这些属性就变成$(element).data().*。我在这里使用它将所有可用页面的列表传输到客户端。

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

{% 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 %}

作为额外的奖励,这使用户能够通过拖放来订购代表所选页面的小部件。

此时,我的问题终于得到了充分的回答。


Select2MultipleField的代码。我建议您使用flask.ext.admin.form.fields运行差异来查找差异。

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))

答案 1 :(得分:-2)

这个答案与flask-admin中的foreignkey上的动态过滤器有关,我认为这是一个很常见的情况,你希望字段B的选择列表取决于字段A的值等。

此链接可能还包含有用的信息:https://github.com/flask-admin/flask-admin/issues/797

通过将on_form_prefill与query_factory一起使用,我找到了一个解决方案,这里是步骤

在admin定义中,覆盖on_form_prefill的默认实现,在该方法中,您可以获取当前正在编辑的对象,这样您就可以根据当前定义的字段定义另一个字段的query_factory,代码如下所示: / p>

class ReceivingAdmin(ModelView):
        def on_form_prefill(self, form, id):

        # Get field(purchase_order_id) from the current object being edited via form._obj.purchase_order_id
        if form is not None and form._obj is not None and form._obj.purchase_order_id is not None:
            po_id = form._obj.purchase_order_id
            # Define a dynamic query factory based on current data.
            # Please notice since the po_id parameter need to be passed to the function,
            # So functools.partial is used
            form.lines.form.purchase_order_line.kwargs['query_factory'] =\
                partial(PurchaseOrderLine.header_filter, po_id)

以下是模型中查询工厂的定义:

class PurchaseOrderLine(db.Model):
    @staticmethod
    def header_filter(po_id):
        return AppInfo.get_db().session.query(PurchaseOrderLine).filter_by(purchase_order_id=po_id)

通过这种方式,我们可以根据参数po_id控制采购订单行列表中显示的记录,并将po_id的值传递给on_form_prefill中的查询工厂函数。