flask-admin 表单:根据字段 1 的值约束字段 2 的值

flask-admin form: Constrain Value of Field 2 depending on Value of Field 1

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

我举个简单的文字例子(实际用例比较复杂)。然后我将展示实现该示例的完整要点,减去 "constrain" 功能。

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

为了实现每个配方,我们从几种方法中选择一种。我们数据库的methodtable有两个方法:"tabulate_results","pretty_print".

每个方法都有参数。 methodarg table 有两个参数名称 "tabulate_results" ("rows", "display_total") 和两个参数 "pretty_print" ("embellishment_character", "lines_to_jump").

现在,对于每个食谱 ("Serious Report"、"ASCII Art"),我们需要提供它们各自方法的参数值 ("tabulate_results"、"pretty_print") .

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

我们可以实现什么过滤/约束机制,一旦我们 select "Serious Report",我们知道我们将使用 "tabulate_results" 方法,所以只有 "rows" 和 "display_total" 个参数可用?

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

您可以通过操作 gist 来查看:单击 Recipe Arg 选项卡。在第一行 ("Serious Report") 中,如果您尝试通过单击来编辑 "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)

我看到有两种方法可以解决这个问题:

1- 当 Flask-Admin 生成表单时,在 [=15= 中的每个 option 标签上添加 data 属性,每个 methodArgmid ] select。然后让一些 JS 代码根据配方 selected.

过滤 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 后,您可以继续在食谱的 select 上绑定 change 并显示方法参数的 option匹配 data-mid。考虑到 Flask-Admin 使用 select2,你可能需要做一些 JS 调整(最简单丑陋的解决方案是清理小部件并为每个触发的 change 事件重新创建它)

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

2- 使用 Flask-Admin 中受支持的 ajax-completion 来根据 selected 配方破解你想要的选项:

首先,创建一个自定义 AjaxModelLoader,它将负责对数据库执行正确的 selection 查询:

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
    };
},

此片段摘自 Flask-Admin 的 form.js [1]

显然,这需要一些调整和参数化(因为做这样一个 hacky 解决方案会阻止您在应用管理的其余部分使用其他 ajax-填充的 select + form.js 直接这样会使升级 Flask-Admin 极其麻烦)

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

我制作了另一个简单的解决方案并且有效

1- 通常创建您的第一个 select 选项,并在其上加载数据并向其添加一个挂钩,当它 select 像这样更改时将添加 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 请求,例如这条路线 returns 房屋列表通过查询来自第一个条目的街道名称来编号

@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 创建一个虚拟 select 条目并将该条目附加到相同的字符串输入容器 将字符串条目设置为只读以改善用户体验 其次,它会向指定路由发送GET请求,获取使用指定街道输入值的门牌号列表 然后获取结果并创建选项元素并将这些选项附加到虚拟selection,您也可以在附加选项时select第一个选项。

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

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

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

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

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

我的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 的字段非预定义选项则无需创建虚拟输入您可以创建新选项并将其直接附加到第二个 select 输入

最终结果