mapio / Flask-Admin-Inline-Models-And-Related-Fields

A Flask-Admin toy application that shows how to solve a couple of common problems with inline models and related fields.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Flask-Admin inline models and related fields with Flask-SQLAlchemy

This repository is meant to show how to solve a couple of problems arising when using inline editing of related models and related (or cascading) fields in Flask-Admin coupled with Flask-SQLAlchemy.

The use case is the following: assume you have a parent-child one-to-many relation such as the relation among cities and streets, company departments and employees, categories and products. Then you may want to have (independent) forms that allow:

  1. to edit the child in the context of the parent, but not to delete it in such context,
  2. to have a first select field for the parent dinamically populating (when changed) a second cascading select field for the child containing just related values.

For example, you want to offer an admin of your application the possibility to rename and add products (but not to delete them), and a visitor of your app to first select a category of products and then choose a specific product in the choosen category.

The application in this repository shows a simple way to achieve both goals with, at the best of my undestanding of the tools, as less code as possible.

How to run the app

As usual you'll need to install the requirements, better if in a virtual enviroment, in the same directory where you have cloned this repo:

python3 -m venv vevn
. ./venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt

You can check in the requirements.txt file the version numbers of the tool this example is guaranteed to work with.

Then you can run the application as usual run the developement server as

flask --debug run --port 8080

Pointing the browser at http://localhost:8080/ you should be able to reach the application.

Pleas note that the application uses a prepupulated SQLite In-Memory Database, so that every restart (caused, for example, by editing the code while the application runs in debug mode), causes a loss of all edits.

The application

The application is built following what is shown in basic tutorials and examples easily obtainable on-line. First some models are defined, then two views and some support code is used to obtain the two goals described above; the juicy part is described in the following views subsection.

Models

First the application is created and configured and connected with SQLAchemy

app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY="dev",
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db = SQLAlchemy(app)

Then the two parent and child models are defined as shown in the SQLAlchemy documentation

class Parent(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, nullable=False)
def __repr__(self):
return self.name
class Child(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, nullable=False)
parent_id = db.Column(db.ForeignKey("parent.id"))
parent = db.relationship("Parent", backref="children")
def __repr__(self):
return self.name

and a third related model is added to store the selections from the form with related, or cascading, fields described at point 2. above

class Related(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, nullable=False)
parent_id = db.Column(db.ForeignKey("parent.id"))
parent = db.relationship("Parent")
child_id = db.Column(db.ForeignKey("child.id"))
child = db.relationship("Child")

The views

The two goals can be obtained independently, the following two subsections show how to implement two distinct views each one solving one of the above points.

Preventing deletion

To inline the child model view in the parent view just set the inline_models attribute of the parent view as usual.

To customize the inline form so that it will not contain the "Delete ?" checkbox:

  • set the inline_model_form_converter attribute of the parent view to a custom converter obtained in turn by
  • subclassing the default converter simply settin the inline_field_list_type attribute to a custom form list obtained in turn by
  • subclassing the default form list by just overriding the display_row_controls method so that it always returns False.

Easily done than said:

class NoDeleteInlineModelFormList(InlineModelFormList):
def display_row_controls(self, field):
return False
class NoDeleteInlineModelConverter(InlineModelConverter):
inline_field_list_type = NoDeleteInlineModelFormList
class ParentView(sqla.ModelView):
inline_models = [Child]
inline_model_form_converter = NoDeleteInlineModelConverter
column_list = ["name", "children"]
column_searchable_list = ["name", "children.name"]

Cascading fields

This goal requires a bit more effort. The idea is to build a form where:

  • the parent select field is handled by Flask-Admin,
  • the related child extra select field is a Select2Field handled by Flask-Admin,
  • some Javascript code uses Ajax to populate the related child choices when the parent changes,
  • an Ajax endpoint is provided by subclassing AjaxModelLoader,
  • some Python code "connects" the value of the related child form field to the model attribute.

Let's start with the attributes part of the view

class RelatedView(sqla.ModelView):
column_list = ["name", "parent", "child"]
form_columns = ["name", "parent", "related_child"]
form_extra_fields = {
"related_child": Select2Field(
"Related child", choices=[], allow_blank=True, validate_choice=False
)
}
form_ajax_refs = {"related_child": AjaxRelatedChildLoader("related_child")}
extra_js = ["/static/related_form.js"]

Here the interesting part is the definition of form_columns [line 83] where related_child substitutes child; since such field has no counterpart in the model, it is added as a form_extra_fields [line 84], where it is defined as a Select2Field. Pay attention to the parameters choices and allow_blank (so defined because the select will be filled dynamically by the custom Javascript) and validate_choice that is set to False as suggested in Skipping choice validation.

Finally we need to configure the view so that it will automatically scaffold a endpoint to answer Ajax queries, this is done setting form_ajax_refs in line 89 to a subclass of AjaxModelLoader and adding some some extra_js [line 90] that we are about to see now.

The code for AjaxRelatedChildLoader is trivial

class AjaxRelatedChildLoader(AjaxModelLoader):
def __init__(self, name, **options):
super(AjaxRelatedChildLoader, self).__init__(name, options)
def format(self, model):
if not model:
return None
return (model.id, str(model))
def get_one(self, pk):
return db.get_or_404(Child, pk)
def get_list(self, query, offset=0, limit=DEFAULT_PAGE_SIZE):
return db.session.execute(
db.select(Child).filter_by(parent_id=query).offset(offset).limit(limit)
).scalars()

remember that this has to provide to the Ajax call a list of child related to the queried parent. This is the reason why in overriding get_one we use the Child model [line 73] and in overriding get_list we also use filter_by(parent_id=query) to restrict the list of children to the ones related to the parent that the Ajax call will put in the query parameter as show in

var $parent = $('#parent');
var $related_child = $('#related_child');
$parent.change(function() {
var $parent_id = $parent.select2('data').id;
$.ajax({
url: '/related/ajax/lookup/',
data: {
name: 'related_child',
query: $parent_id
},
success: function(data) {
$related_child.empty();
$related_child.append(
new Option(' ', '__None', false, false)
);
data.forEach(function(elem) {
$related_child.append(
new Option(elem[1], elem[0], false, false)
);
});
$related_child.trigger('change');
}
});
});

Again the relevant parts are is definition of the on-change handler [line 4]. The Ajax call is based on the $parent_id extracted [line 8] from the parent field (handled by Flask-Admin) and used as the query parameter [line 10] (together with the name of the endpoint [line 9]) and on the endpoint (generated by Flask-Admin due the the form_ajax_refs configuration in the view) indicated [line 7]. The callback for the Ajax call [lines 12-23] receives the data and uses it to populate the select field (once reset to the single empty option) and triggers a change event [line 22] so that the browser can update the form.

What remains to be done is to connect the value of the related child form field to the child attribute of the model. This is done in Python by overriding two methods of the view

def on_form_prefill(self, form, id):
model = self.get_one(id)
form.related_child.choices = [
(str(child.id), child.name) for child in model.parent.children
]
form.related_child.data = str(model.child_id)
def on_model_change(self, form, model, is_created):
model.child_id = (
int(form.related_child.data) if form.related_child.data else None
)

As its name suggests on_form_prefill is called once the edit form has been scaffolded and some values are yet to be filled; in our case we need to fill form.related_child with the possible choiices obtained from the model via model.parent.children (the siblings, so to say, of the current choice) and the data with the current selected child. On the other end, once the form has been submitted and a change in the model has been detected (due to an edit, or a create) overriding on_model_change allows us to set model.child_id from the form.related_child.data value.

Putting things together

Once the views are defined, it's enough to instantiate the administrative backend and to add to it the views

admin = Admin(
app,
index_view=AdminIndexView(url="/"),
name="Related fields example",
template_mode="bootstrap3",
)
admin.add_view(ParentView(Parent, db.session))
admin.add_view(RelatedView(Related, db.session))

Finally, to show some data when the application starts, few records are used to prepopulate each model

with app.app_context():
db.create_all()
p0 = Parent(name="parent 0")
c00 = Child(name="child 0 of parent 0", parent=p0)
c10 = Child(name="child 1 of parent 0", parent=p0)
c20 = Child(name="child 2 of parent 0", parent=p0)
p1 = Parent(name="parent 1")
c01 = Child(name="child 0 of parent 1", parent=p1)
c11 = Child(name="child 1 of parent 1", parent=p1)
r0 = Related(name="related 0", parent=p0, child=c10)
db.session.add(p0)
db.session.add(p1)
db.session.add(r0)
db.session.commit()

About

A Flask-Admin toy application that shows how to solve a couple of common problems with inline models and related fields.

License:GNU General Public License v3.0


Languages

Language:Python 85.6%Language:JavaScript 14.4%