One feature I have been struggling to implement in flask-admin is when the user edits a form, to constrain the value of Field 2 once Field 1 has been set.
Let me give a simplified example in words (the actual use case is more convoluted). Then I will show a full gist that implements that example, minus the “constrain” feature.
Let’s say we have a database that tracks some software “recipes” to output reports in various formats. The recipe
table of our sample database has two recipes: “Serious Report”, “ASCII Art”.
To implement each recipe, we choose one among several methods. The method
table of our database has two methods: “tabulate_results”, “pretty_print”.
Each method has parameters. The methodarg
table has two parameter names for “tabulate_results” (“rows”, “display_total”) and two parameters for “pretty_print” (“embellishment_character”, “lines_to_jump”).
Now for each of the recipes (“Serious Report”, “ASCII Art”) we need to provide the value of the arguments of their respective methods (“tabulate_results”, “pretty_print”).
For each record, the recipearg
table lets us select a recipe (that’s Field 1, for instance “Serious Report”) and an argument name (that’s Field 2). The problem is that all possible argument names are shown, whereas they need to be constrained based on the value of Field 1.
What filtering / constraining mechanism can we implement such that once we select “Serious Report”, we know we will be using the “tabulate_results” method, so that only the “rows” and “display_total” arguments are available?
I’m thinking some AJAX wizardry that checks Field 1 and sets a query for Field 2 values, but have no idea how to proceed.
You can see this by playing with the gist: click on the Recipe Arg
tab. In the first row (“Serious Report”), if you try to edit the “Methodarg” value by clicking on it, all four argument names are available, instead of just two.
# 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)
Advertisement
Answer
I see two ways of tacking this problem:
1- When Flask-Admin generate the form, add data
attributes with the mid
of each methodArg
on each option
tag in the methodArg
select. Then have some JS code filter the option
tags based on the recipe selected.
EDIT
Here is a tentative try at putting a data-mid
attribute on each option
:
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
The blocker is in the fact that those render calls are triggered from the jinja templates, so you are pretty much stuck updating a widget (Select
being the most low-level one in WTForms, and is used as a base for Flask-Admin’s Select2Field
).
After getting those data-mid
on each of your options, you can proceed with just binding an change
on your recipe’s select and display the methodarg’s option
that have a matching data-mid
. Considering Flask-Admin uses select2
, you might have to do some JS tweaking (easiest ugly solution would be to clean up the widget and re-create it for each change
event triggered)
Overall, I find this one less robust than the second solution. I kept the monkeypatch to make it clear this should not be used in production imho. (the second solution is slightly less intrusive)
2- Use the supported ajax-completion in Flask-Admin to hack your way into getting the options that you want based on the selected recipe:
First, create a custom AjaxModelLoader that will be responsible for executing the right selection query to the 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
Then, update Flask-Admin’s form.js
to get the browser to send you the recipe information instead of the methodArg
name that needs to be autocompleted. (or you could send both in query
and do some arg parsing in your AjaxLoader since Flask-Admin does no parsing whatsoever on query
, expecting it to be a string I suppose [0]. That way, you would keep the auto-completion)
data: function(term, page) { return { query: $('#recipe').val(), offset: (page - 1) * 10, limit: 10 }; },
This snippet is taken from Flask-Admin’s form.js
[1]
Obviously, this needs some tweaking and parametrising (because doing such a hacky solution would block you from using other ajax-populated select in the rest of your app admin + the update on form.js
directly like that would make upgrading Flask-Admin
extremely cumbersome)
Overall, I am unsatisfied with both solutions and this showcase that whenever you want to go out of the tracks of a framework / tool, you can end up in complex dead ends. This might be an interesting feature request / project for someone willing to contribute a real solution upstream to Flask-Admin though.