【问题标题】:How to handle ordered many-to-many relationship (association proxy) in Flask-Admin form?如何在 Flask-Admin 表单中处理有序的多对多关系(关联代理)?
【发布时间】:2014-09-08 14:09:11
【问题描述】:

我在声明性模型 PageSurvey 之间存在多对多关系,这是由关联代理中介的,因为页面在调查中出现的顺序是很重要,所以交联表多了一个字段。

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

【问题讨论】:

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


    【解决方案1】:

    准备好最终答案

    下面的第一部分是原始答案,完成答案的附加部分附加在末尾。

    原答案:存储输入

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

    如果我自己找到最终解决方案,我将编辑这篇文章。赏金将颁发给首先填补最后空白的任何人。如果您有黄金提示,请提交新答案!

    令我惊讶的是,我还不需要对模板做任何事情。诀窍主要在于避免将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 应用程序是作为具有多个模块和子包的包实现的,并且我避免了循环依赖。我已经通过在我的create_admin 函数中导入包含SurveyView 类的模块来解决它:

    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
    

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


    补充:预填充 select2 字段

    flask.ext.admin.model.base.BaseModelView.edit_view 中自动预填充 Flask-Admin 知道如何处理的表单字段。不幸的是,开箱即用它不提供任何挂钩 à la 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)
    
            # ...
    

    为了不对不使用钩子的模型视图造成问题,派生类显然还必须提供一个无操作作为默认值:

        def on_form_prefill (self, form, id):
            pass
    

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

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

        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 的覆盖,因为我需要该类的附加功能,但 edit_view 通常只在 flask.ext.admin.model.base.BaseModelView 中定义。)

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


    补充:固定顺序

    事实证明,如果基础表单字段是&lt;select&gt;,则 Select2 无法保持顺序。 Select2 文档建议将&lt;input type="hidden"&gt; 用于可排序的多选,因此我基于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))
    

    【讨论】:

    【解决方案2】:

    这个答案与烧瓶管理中外键的动态过滤器有关,我认为这是一种非常常见的情况,当您希望字段 B 的选择列表取决于字段 A 的值等时。

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

    我已经通过使用 on_form_prefill 和 query_factory 找到了解决方案,这里是步骤

    在admin定义中,覆盖on_form_prefill的默认实现,在该方法中可以获取当前正在编辑的对象,因此可以根据当前定义的字段定义另一个字段的query_factory,代码如下:

    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中的查询工厂函数。

    【讨论】:

    • 首先,这不是问题的答案。它可能会回答一个相关的问题,但这种关系并不明显,你不会特意去解释它。其次,我知道解决方案在on_form_prefill,因为因为这个问题和我自己对它的回答,我将它添加到了Flask-Admin。感谢您指出我自己的解决方案是一个解决方案。
    • 我发布这个答案希望它可以帮助其他正在寻找此类问题答案的人,我已经知道您创建了推送请求并且它已合并到 flask-admin 中。我可能需要声明这里的答案与您最初的要求相关但不完全相同。
    猜你喜欢
    • 2011-06-18
    • 1970-01-01
    • 1970-01-01
    • 2021-01-13
    • 2017-11-22
    • 1970-01-01
    • 2021-01-28
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多