【问题标题】:flask-admin form: Constrain Value of Field 2 depending on Value of Field 1flask-admin 表单:根据字段 1 的值约束字段 2 的值
【发布时间】:2016-02-13 03:38:12
【问题描述】:

我在 flask-admin 中一直在努力实现的一个功能是,当用户编辑表单时,一旦设置了字段 1,就限制了字段 2 的值。

让我用文字举一个简化的例子(实际用例更复杂)。然后我将展示实现该示例的完整要点,减去“约束”功能。

假设我们有一个数据库,可以跟踪一些软件“配方”以输出各种格式的报告。我们示例数据库的recipe 表有两个配方:“Serious Report”、“ASCII Art”。

为了实现每个配方,我们从几种方法中选择一种。我们数据库的method表有两种方法:“tabulate_results”、“pretty_print”。

每个方法都有参数。 methodarg 表有两个用于“tabulate_results”的参数名称(“rows”、“display_total”)和两个用于“pretty_print”的参数(“embellishment_character”、“lines_to_jump”)。

现在,对于每个配方(“Serious Report”、“ASCII Art”),我们需要提供它们各自方法(“tabulate_results”、“pretty_print”)的参数值。

对于每条记录,recipearg 表允许我们选择一个配方(即字段 1,例如“Serious Report”)和参数名称(即字段 2)。问题是显示了所有可能的参数名称,而它们需要根据字段 1 的值进行约束。

我们可以实现什么过滤/约束机制,一旦我们选择“Serious Report”,我们就知道我们将使用“tabulate_results”方法,因此只有“rows”和“display_total”参数可用?

我在想一些 AJAX 魔法,它检查字段 1 并设置字段 2 值的查询,但不知道如何继续。

您可以通过使用要点来了解这一点:单击Recipe Arg 选项卡。在第一行(“严重报告”)中,如果您尝试通过单击来编辑“Methodarg”值,则所有四个参数名称都可用,而不仅仅是两个。

# full gist: please run this

from flask import Flask
from flask_admin import Admin
from flask_admin.contrib import sqla
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship

# Create application
app = Flask(__name__)

# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///a_sample_database.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)

# Create admin app
admin = Admin(app, name="Constrain Values", template_mode='bootstrap3')

# Flask views
@app.route('/')
def index():
    return '<a href="/admin/">Click me to get to Admin!</a>'


class Method(db.Model):
    __tablename__ = 'method'
    mid = Column(Integer, primary_key=True)
    method = Column(String(20), nullable=False, unique=True)
    methodarg = relationship('MethodArg', backref='method')
    recipe = relationship('Recipe', backref='method')


    def __str__(self):
        return self.method


class MethodArg(db.Model):
    __tablename__ = 'methodarg'
    maid = Column(Integer, primary_key=True)
    mid = Column(ForeignKey('method.mid', ondelete='CASCADE', onupdate='CASCADE'), nullable=False)
    methodarg = Column(String(20), nullable=False, unique=True)
    recipearg = relationship('RecipeArg', backref='methodarg')
    inline_models = (Method,)


    def __str__(self):
        return self.methodarg


class Recipe(db.Model):
    __tablename__ = 'recipe'
    rid = Column(Integer, primary_key=True)
    mid = Column(ForeignKey('method.mid', ondelete='CASCADE', onupdate='CASCADE'), nullable=False)
    recipe = Column(String(20), nullable=False, index=True)
    recipearg = relationship('RecipeArg', backref='recipe')
    inline_models = (Method,)

    def __str__(self):
        return self.recipe


class RecipeArg(db.Model):
    __tablename__ = 'recipearg'

    raid = Column(Integer, primary_key=True)
    rid = Column(ForeignKey('recipe.rid', ondelete='CASCADE', onupdate='CASCADE'), nullable=False)
    maid = Column(ForeignKey('methodarg.maid', ondelete='CASCADE', onupdate='CASCADE'), nullable=False)
    strvalue = Column(String(80), nullable=False)
    inline_models = (Recipe, MethodArg)


    def __str__(self):
        return self.strvalue


class MethodArgAdmin(sqla.ModelView):
    column_list = ('method', 'methodarg')
    column_editable_list = column_list



class RecipeAdmin(sqla.ModelView):
    column_list = ('recipe', 'method')
    column_editable_list = column_list



class RecipeArgAdmin(sqla.ModelView):
    column_list = ('recipe', 'methodarg', 'strvalue')
    column_editable_list = column_list


admin.add_view(RecipeArgAdmin(RecipeArg, db.session))

# More submenu
admin.add_view(sqla.ModelView(Method, db.session, category='See Other Tables'))
admin.add_view(MethodArgAdmin(MethodArg, db.session, category='See Other Tables'))
admin.add_view(RecipeAdmin(Recipe, db.session, category='See Other Tables'))


if __name__ == '__main__':

    db.drop_all()
    db.create_all()
    db.session.add(Method(mid=1, method='tabulate_results'))
    db.session.add(Method(mid=2, method='pretty_print'))
    db.session.commit()
    db.session.add(MethodArg(maid=1, mid=1, methodarg='rows'))
    db.session.add(MethodArg(maid=2, mid=1, methodarg='display_total'))
    db.session.add(MethodArg(maid=3, mid=2, methodarg='embellishment_character'))
    db.session.add(MethodArg(maid=4, mid=2, methodarg='lines_to_jump'))
    db.session.add(Recipe(rid=1, mid=1, recipe='Serious Report'))
    db.session.add(Recipe(rid=2, mid=2, recipe='ASCII Art'))
    db.session.commit()
    db.session.add(RecipeArg(raid=1, rid=1, maid=2, strvalue='true' ))
    db.session.add(RecipeArg(raid=2, rid=1, maid=1, strvalue='12' ))
    db.session.add(RecipeArg(raid=3, rid=2, maid=4, strvalue='3' ))
    db.session.commit()

    # Start app
    app.run(debug=True)

【问题讨论】:

  • 对于灵活的数据库接口,此功能必须具备。一个好的答案会帮助很多人。当然,数据本身可以有不同的结构,但这不是重点。添加赏金。 :)

标签: python flask sqlalchemy flask-sqlalchemy flask-admin


【解决方案1】:

我看到了解决这个问题的两种方法:

1- 当 Flask-Admin 生成表单时,在 methodArg 选择中的每个 option 标记上添加 data 属性和每个 methodArgmid。然后让一些 JS 代码根据所选配方过滤 option 标签。

编辑

这是一个尝试性的尝试,在每个 option 上放置一个 data-mid 属性:

def monkeypatched_call(self, field, **kwargs):
    kwargs.setdefault('id', field.id)
    if self.multiple:
        kwargs['multiple'] = True
    html = ['<select %s>' % html_params(name=field.name, **kwargs)]
    for (val, label, selected), (_, methodarg) in zip(field.iter_choices(), field._get_object_list()):
        html.append(self.render_option(val, label, selected, **{'data-mid': methodarg.mid}))
    html.append('</select>')
    return HTMLString(''.join(html))

Select.__call__ = monkeypatched_call

阻碍在于这些渲染调用是从 jinja 模板触发的,因此您几乎无法更新小部件(Select 是 WTForms 中最低级别的小部件,并用作Flask-Admin 的 Select2Field)。

在每个选项上获得 data-mid 后,您可以继续在配方的选择上绑定 change 并显示具有匹配 data-mid 的方法参数的 option。考虑到 Flask-Admin 使用 select2,您可能需要进行一些 JS 调整(最简单的丑陋解决方案是清理小部件并为每个触发的 change 事件重新创建它)

总的来说,我发现这个解决方案不如第二种解决方案强大。我保留了monkeypatch以明确这不应该在生产中使用恕我直言。 (第二种解决方案的侵入性稍小)

2- 使用 Flask-Admin 中支持的 ajax-completion 破解您的方式,根据所选配方获取您想要的选项:

首先,创建一个自定义的 AjaxModelLoader,它将负责对 DB 执行正确的选择查询:

class MethodArgAjaxModelLoader(sqla.ajax.QueryAjaxModelLoader):
    def get_list(self, term, offset=0, limit=10):
        query = self.session.query(self.model).filter_by(mid=term)
        return query.offset(offset).limit(limit).all()

class RecipeArgAdmin(sqla.ModelView):
    column_list = ('recipe', 'methodarg', 'strvalue')
    form_ajax_refs = {
        'methodarg': MethodArgAjaxModelLoader('methodarg', db.session, MethodArg, fields=['methodarg'])
    }
    column_editable_list = column_list

然后,更新 Flask-Admin 的 form.js 以让浏览器向您发送配方信息,而不是需要自动完成的 methodArg 名称。 (或者您可以同时发送query 并在您的 AjaxLoader 中进行一些 arg 解析,因为 Flask-Admin 不会对 query 进行任何解析,希望它是一个字符串,我想是 [0]。这样,您将保留自动完成)

data: function(term, page) {
    return {
        query: $('#recipe').val(),
        offset: (page - 1) * 10,
        limit: 10
    };
},

这个 sn-p 取自 Flask-Admin 的 form.js [1]

显然,这需要一些调整和参数化(因为这样做会阻止您在应用程序管理员的其余部分中使用其他 ajax 填充的选择 + 直接在 form.js 上进行更新,这样会升级 @987654351 @极其繁琐)

总的来说,我对这两种解决方案都不满意,而且这个展示表明,无论何时你想走出框架/工具的轨道,你最终都会陷入复杂的死胡同。不过,对于愿意为 Flask-Admin 上游贡献真实解决方案的人来说,这可能是一个有趣的功能请求/项目。

【讨论】:

  • 如果您显示工作代码,会很想看到#1,解决方案 +1。
  • 非常感谢您的回答。现在患重感冒,但期待尝试您的想法(不确定我是否完全理解第一个)。你认为你的第一个想法是最强大的吗?顺便说一句,如果你不得不猜测,这种功能是否应该在某个阶段进入flask-admin
  • Hans Schindler@: 我会努力为 1-.我现在最大的障碍是让 Flask-Admin 在option 标签上添加data 属性。 droptable@: 希望得到一些 1 的代码会让它更清晰 ;) 至于最健壮的,两者都是 hacks 并且 1 也有一些缺点:你需要事先获取所有 methodArgs 的列表,以便 JS 可以有一个足够大的图片为您进行过滤。最后,我不是 Flask-Admin 的维护者,我猜这是一个非常困难的功能,要在一般情况下足够正确,仍然可以被认为是一个边缘案例。
  • 我尝试了第一个解决方案,但它最终变得更加麻烦,因为您基本上必须劫持 WTForms 而不是 Flask-Admin。你可以通过使用类似 flask-admin.readthedocs.org/en/latest/advanced/… 的东西来摆脱猴子补丁(这也可以让你在页面中的每个 select 上都没有 data-mid)。 stackoverflow.com/questions/23023788/… 讨论了向 WTForm 的 SelectField 中的选项添加自定义属性的问题。
  • 感谢您的出色工作(已接受),让您进入flask-admin hall of fame :) 这表明没有简单的方法。我希望flask-admin 团队在某个阶段添加这个急需的功能。暂时我会忘记 flask-admin 并从头开始制作我的界面。
【解决方案2】:

我制作了另一个简单的解决方案,它可以工作

1- 通常创建您的第一个选择选项并在其上加载数据并添加一个钩子,当它选择这样的更改时将添加 js 事件侦听器。

from wtforms import SelectField

form_extra_fields = {
    'streetname': SelectField(
        'streetname',
        coerce=str,
        choices=([street.streetname for street in StreetsMetadata.query.all()]),
        render_kw={'onchange': "myFunction()"}
        )
    }

**2- 例如,将 JavaScript URL 文件添加到要在其中使用此功能的视图。

def render(self, template, **kwargs):
    #using extra js in render method allow use url_for that itself requires an app context

    self.extra_js = [url_for("static", filename="admin/js/users.js")]
    response = render_miror(self, template,**kwargs)
    return response

3- 创建一个用于此视图的受角色保护的端点,它将根据为条目指定的第一个值接受来自 JS 的 GET 请求,例如,此路由通过查询街道名称返回门牌号列表来自第一个条目

@super_admin_permission.require(http_exception=403)
@adminapp.route('/get_houses_numbers')
def gethouses():
    request_data = request.args
    if request_data and 'street' in request_data:
        street = StreetsMetadata.query.filter(StreetsMetadata.streetname == request_data['street']).one_or_none()
        street_houses = lambda:giveMeAllHousesList(street.excluded, street.min, street.max)
        if street_houses:
            return jsonify({'code': 200, 'houses': street_houses()})
        else:
            return jsonify({'code': 404, 'houses': []})
    else:
        return jsonify({'code': 400, 'street': []})

现在python部分完成了JavaScript的时间

4- 我们必须定义三个函数,第一个将在表单构建页面加载时调用,它首先做两件事, 将使用 JS 创建一个虚拟选择条目并将该条目附加到相同的字符串输入容器 将字符串条目设为只读以改善用户体验 其次,它将使用指定的街道输入值向指定路由发送 GET 请求以获取门牌号列表 然后获取结果并创建选项元素并将这些选项附加到虚拟选择中,您也可以在附加选项时选择第一个选项。

5-第二个函数“myFunction”是本部分Python中定义的钩子

 render_kw={'onchange': "myFunction()"}

这个函数不会做任何新的事情,它只会在第一个指定的输入值改变时发送一个 GET 请求,发送一个 GET 请求以根据给定的街道名称输入值通过查询获取新门牌号列表在数据库上,然后转储虚拟选择条目的内部 HTML,然后创建并附加新选项。

6- 最后一个函数是回调函数,当用户选择将反映在主字符串条目中的门牌号时,它会监听用JS创建的虚拟选择条目的变化,最后你可以点击保存,你会看看它的工作原理

请注意,我创建的整个想法不如内置的烧瓶管理员,但如果您正在寻找最终目标并且没有任何问题,您可以使用它

我的 JS 代码

/*
  This Function when run when a form included it will create JS select input with the
  default loaded streetname and add house number on that select this select will used
  to guide creator of the house number or to select the house number
*/
async function onFlaskFormLoad(){

  const streetSelect = document.querySelector("#streetname");
  const checkIfForm = document.querySelector("form.admin-form");
  if (checkIfForm){
    let checkSelect = document.querySelector("#realSelect");
    if (!checkSelect){
      const mySelectBox = document.createElement("select");
      const houseString = document.querySelector("#housenumber");
      const houseStringCont = houseString.parentElement;
      mySelectBox.classList.add("form-control")
      mySelectBox.id = "realSelect";
      houseStringCont.appendChild(mySelectBox);
      mySelectBox.addEventListener("change", customFlaskAdminUnpredefinedSelect);
      houseString.setAttribute("readonly", "readonly");
      const res = await fetch(`/get_houses_numbers?street=${streetSelect.value}`);
      const data = await res.json();
      console.log(data);
      if (data.code == 200 && mySelectBox){
        data.houses.forEach( (houseOption, index)=>{
          if (index == 0){
            houseString.value = houseOption;
          }
          let newHouse = document.createElement("option");
          newHouse.setAttribute("value", houseOption);
          newHouse.innerText = houseOption;
          mySelectBox.appendChild(newHouse);
        });
      }


    }
  }
}

onFlaskFormLoad();


/*
  this function will called to change the string input value to my custom js select
  value and then use that string to house number which required by flask-admin
*/
function customFlaskAdminUnpredefinedSelect(){
  const theSelect = document.querySelector("#realSelect");
  const houseString = document.querySelector("#housenumber");
  houseString.value = theSelect.value;
  return true;
}
/*
   flask admin hook that will listen on street input change and then it will send
   get request to secured endpoint with role superadmin required and get the housenumbers
   using the streetname selected and then create options and add to my select input
*/
async function myFunction(){
  const streetSelect = document.querySelector("#streetname");
  const houseString = document.querySelector("#housenumber");
  const houseStringCont = houseString.parentElement;

  const theSelect = document.querySelector("#realSelect");
  const res = await fetch(`/get_houses_numbers?street=${streetSelect.value}`);
  const data = await res.json();
  console.log(data);
  if (data.code == 200 && theSelect){
    theSelect.innerHTML = "";
    data.houses.forEach( (houseOption, index)=>{
      if (index == 0){
        houseString.value = houseOption;
      }
      let newHouse = document.createElement("option");
      newHouse.setAttribute("value", houseOption);
      newHouse.innerText = houseOption;
      theSelect.appendChild(newHouse);
    });
  }
}

现在,如果我更改第一个指定输入的街道名称,我将获得一个包含基于第一个输入值的数字的新列表,请注意,如果您有办法创建一个接受非预定义选项的 python 字段,那么无需创建虚拟输入,您可以创建并将新选项直接附加到第二个选择输入

最终结果

【讨论】:

    猜你喜欢
    • 2013-02-10
    • 2021-01-07
    • 2011-06-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-04-22
    • 1970-01-01
    • 2018-05-09
    相关资源
    最近更新 更多