Eliminate repetition and improve maintenance by creating a Flask view class
Using a view class instead of view functions is better because it allows us to share code instead of duplicating and modifying it.
Flask is hot. Everyone loves Flask. I believe the main reason is that it is so easy to start with Flask. You create a virtual environment, copy-paste a few lines of code from some example, point your browser at 127.0.0.1:5000 and there is your page. Then you hack a little with a Jinja template and you get a beautiful page. You can even run Flask on a Raspberry Pi, isn't that wonderful?
My main reason to start using Flask was building an application and learning, Flask is a microframework meaning you must create most other things yourself. Of course you can use extensions but as I wrote in a previous post, I do not want to depend too much on extensions. They may get unsupported tomorrow and then what will you do?
Blueprints and views
When your application grows you start using the application factory pattern, using create_app(), and start using Blueprints. This gives structure to your application. Nobody tells you to do this, with Flask there are no rules, you just follow the suggestions of the Flask documentation and others. I created a sub directory blueprints with directories for the administrator functions of the site. Examples are user, user_group, page_request, access.
My first view function was ugly, but it worked. After some time I refined it, see the examples on the internet. But my application kept getting bigger and I was doing copy-paste for new view functions. Not exactly copy-paste because a number of things needed to be changed for every model. Some views were unique, like the content_item view, but many others were just using a SQLAlchemy model, you know, you create, edit, delete and have a view to list the records.
Copy-paste has one very big problem, it is almost impossible to maintain. An example of the edit method that is copied, pasted and then modified:
@admin_user_blueprint.route('/user/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def user_edit(id):
user = db_select(
model_class_list=[User],
filter_by_list=[
(User, 'id', 'eq', id),
],
).first()
if user is None:
abort(403)
form = UserEditForm(obj=user)
if form.validate_on_submit():
# update and redirect
form.populate_obj(user)
g.db_session.commit()
flash( _('User %(username)s updated.', username=user.username), 'info')
return redirect(url_for('admin_user.users_list'))
return render_template(
'shared/new_edit.html',
page_title=_('Edit user'),
form=form,
user=user,
)
I already was using 'shared' templates. I have a template for 'new and edit', and a template for 'delete'. I did not have a 'shared' template for 'list'. Anyway, this helped a little but I knew one day I had to redo the view functions. Few weeks ago I had to add another function to the administrator and while copying code I decided it was enough. I must stop this and put my efforts in writing a base CRUD (Create-Read-Update-Delete) model view class that I can use in these cases. Start simple, expand later.
How others did this
Using a view class instead of view functions is better because it allows us to share code instead of duplicating and modifying it. Of course many others recognized this before and implemented their own solutions. Take a look at Flask Pluggable Views. This is just very basic. Two good examples can be found in Flask-AppBuilder and Flask-Admin. I suggest you take a look at their code, see the links below. However, you know me, instead of copying code I wanted to do it myself. My first version wil be nothing compared to the two mentioned but at least I learn a lot.
The CRUD model view class
My first version is limited to a single (SQLAlchemy) model. The class must have list, new, edit, and delete methods. I also want it to be instantiated in the Blueprint where it belongs. It use the forms from the Blueprint directory, so no changes here, for example the page_request and user Blueprint directories:
.
|-- blueprints
| |
| |-- page_request
| | |-- forms.py
| | |-- __init__.py
| | `-- views.py
| |-- user
| | |-- forms.py
| | |-- __init__.py
| | `-- views.py
The most challenging part for me was to add the urls of the methods to Flask. This must be done when the CRUD view Object is created using the Flask add_url_rule() method. The CRUD view Object is created at initialization time. This means you cannot use url_for() here because the routes are not known at this time. Another challenge was arranging the number of parameters that must be passed during object creation. This should be minimal! For example, the class should create the urls for new, edit and delete by itself from a base url. I created a class CRUDView in a file class_crud_view.py that looks like this:
class CRUDView:
def __init__(self,
blueprint = None,
model = None,
...
):
self.blueprint = blueprint
self.model = model
...
# construct rules, endpoints, view_funcs for list, new, edit, delete
# because we are using a blueprint, the url_rule endpoint is the method
methods = ['GET', 'POST']
self.operations = {
'list': {
'url_rules': [
...
],
},
'new': {
'url_rules': [
...
],
},
'edit': {
'url_rules': [
...
],
},
'delete': {
'url_rules': [
...
],
},
}
# register urls
for operation, operation_defs in self.operations.items():
for url_rule in operation_defs['url_rules']:
self.blueprint.add_url_rule(url_rule['rule'], **url_rule['args'])
def list(self, page_number):
...
def new(self):
...
def edit(self, item_id):
...
def delete(self, item_id):
...
url_rules is a list because a method can have more than one route. For example the users list method has two routes, a route without and a route with a page number:
@admin_user_blueprint.route('/users/list', defaults={'page_number': 1})
@admin_user_blueprint.route('/users/list/<int:page_number>')
def users_list(page_number):
...
The list method must be flexible, I should be able to specify fields, field types, names. The new, edit and delete methods are more or less copies of existing methods.
Implementation
My implementation will not work for you because I use a pagination function, but here is my CRUD view class, I left out some parts:
class CRUDView:
def __init__(self,
blueprint = None,
model = None,
list_base_rule = None,
list_method = None,
list_page_title = None,
list_page_content_title = None,
crud_item_base_rule = None,
crud_item_base_method = None,
crud_item = None,
list_items_per_page = None,
list_columns = None,
form_new = None,
form_edit = None,
form_delete = None
):
self.blueprint = blueprint
self.model = model
self.list_base_rule = list_base_rule
self.list_method = list_method
self.list_page_title = list_page_title
self.list_page_content_title = list_page_content_title
self.crud_item_base_rule = crud_item_base_rule
self.crud_item_base_method = crud_item_base_method
self.crud_item = crud_item
self.list_items_per_page = list_items_per_page
self.list_columns = list_columns
self.form_new = form_new
self.form_edit = form_edit
self.form_delete = form_delete
if self.list_page_content_title is None:
self.list_page_content_title = self.list_page_title
# construct rules, endpoints, view_funcs for list, new, edit, delete
# because we using a blueprint, the url_rule endpoint is the method
methods = ['GET', 'POST']
self.operations = {
'list': {
'url_rules': [
{
'rule': self.list_base_rule,
'args' : {
'endpoint': self.list_method,
'view_func': self.list,
'methods': methods,
'defaults': {'page_number': 1},
},
},
{
'rule': self.list_base_rule + '/<int:page_number>',
'args' : {
'endpoint': self.list_method,
'view_func': self.list,
'methods': methods,
},
},
],
},
'new': {
'url_rules': [
{
'rule': self.crud_item_base_rule + 'new',
'args' : {
'endpoint': self.crud_item_base_method + 'new',
'view_func': self.new,
'methods': methods,
},
},
],
},
'edit': {
'url_rules': [
{
'rule': self.crud_item_base_rule + 'edit/<int:item_id>',
'args' : {
'endpoint': self.crud_item_base_method + 'edit',
'view_func': self.edit,
'methods': methods,
},
},
],
},
'delete': {
'url_rules': [
{
'rule': self.crud_item_base_rule + 'delete/<int:item_id>',
'args' : {
'endpoint': self.crud_item_base_method + 'delete',
'view_func': self.delete,
'methods': methods,
},
},
],
},
}
# for easy access
self.list_endpoint = self.blueprint.name + '.' + self.list_method
self.new_endpoint = self.blueprint.name + '.' + self.crud_item_base_method + 'new'
self.edit_endpoint = self.blueprint.name + '.' + self.crud_item_base_method + 'edit'
self.delete_endpoint = self.blueprint.name + '.' + self.crud_item_base_method + 'delete'
# register urls
for operation, operation_defs in self.operations.items():
for url_rule in operation_defs['url_rules']:
self.blueprint.add_url_rule(url_rule['rule'], **url_rule['args'])
def list(self, page_number):
# get all items for pagination
total_items_count = db_select(
model_class_list=[(self.model, 'id')],
).count()
pagination = Pagination(items_per_page=self.list_items_per_page)
pagination.set_params(page_number, total_items_count, self.list_endpoint)
# get items for page
items = db_select(
model_class_list=[self.model],
order_by_list=[
(self.model, 'id', 'desc'),
],
limit=pagination.limit,
offset=pagination.offset,
).all()
return render_template(
'shared/items_list.html',
action='List',
page_title=self.list_page_title
pagination=pagination,
page_number=page_number,
items=items,
list_columns=self.list_columns,
item_edit_endpoint=self.edit_endpoint,
item_delete_endpoint=self.delete_endpoint,
new_button={
'name': 'New' + ' ' + self.crud_item['name_lc'],
'endpoint': self.new_endpoint,
}
)
def new(self):
form = self.form_new()
if form.validate_on_submit():
# add and redirect
crud_model = self.model()
form.populate_obj(crud_model)
g.db_session.add(crud_model)
g.db_session.commit()
flash( self.crud_item['name'] + ' ' + _('added') + ': ' + getattr(crud_model, self.crud_item['attribute']), 'info')
return redirect( url_for(self.list_endpoint) )
return render_template(
'shared/item_new_edit.html',
action='Add',
page_title=_('New') + ' ' + self.crud_item['name_lc'],
form=form,
back_button = {
'name': _('Back to list'),
'url': url_for(self.list_endpoint),
}
)
def edit(self, item_id):
# get item
item = db_select(
model_class_list=[self.model],
filter_by_list=[
(self.model, 'id', 'eq', item_id),
],
).first()
if item is None:
abort(403)
form = self.form_edit(obj=item)
if form.validate_on_submit():
# update and redirect
form.populate_obj(item)
g.db_session.commit()
flash( self.crud_item['name'] + ' ' + _('updated') + ': ' + getattr(item, self.crud_item['attribute']), 'info')
return redirect( url_for(self.list_endpoint) )
return render_template(
'shared/item_new_edit.html',
action='Edit',
page_title=_('Edit') + ' ' + self.crud_item['name_lc'],
form=form,
item=item,
back_button = {
'name': _('Back to list'),
'url': url_for(self.list_endpoint),
}
)
def delete(self, item_id):
# get item
item = db_select(
model_class_list=[self.model],
filter_by_list=[
(self.model, 'id', 'eq', item_id),
],
).first()
if item is None:
abort(403)
form = self.form_delete(obj=item)
if form.validate_on_submit():
# delete and redirect
item.status = STATUS_DELETED
g.db_session.commit()
flash( self.crud_item['name'] + ' ' + _('deleted') + ': ' + getattr(item, self.crud_item['attribute']), 'info')
return redirect( url_for(self.list_endpoint) )
return render_template(
'shared/item_delete.html',
action='Delete',
page_title=_('Delete') + ' ' + self.crud_item['name_lc'],
form=form,
item_name=getattr(item, self.crud_item['attribute']),
back_button = {
'name': _('Back to list'),
'url': url_for(self.list_endpoint),
}
)
In a Blueprint I instantiate the class as follows:
city_demo_crud_view = CRUDView(
blueprint = frontend_demo_crud_view_blueprint,
model = DemoCRUDViewCity,
list_base_rule = '/cities/list',
list_method = 'cities_list',
list_page_title = _('Cities'),
crud_item_base_rule = '/city/',
crud_item_base_method = 'city_',
crud_item = {
'name': _('City'),
'name_lc': _('city'),
'attribute': 'name'
},
list_items_per_page = DEMO_CRUD_VIEW_CITY_LIST_PAGINATION_CITIES_PER_PAGE,
list_columns = [
{
'attribute': 'name',
'th': {
'name': _('Name'),
},
'td': {
},
},
{
'attribute': 'created_on',
'th': {
'name': _('Created'),
},
'td': {
},
},
{
'attribute': 'updated_on',
'th': {
'name': _('Updated'),
},
'td': {
},
},
],
form_new = DemoCRUDViewCityNewForm,
form_edit = DemoCRUDViewCityEditForm,
form_delete = DemoCRUDViewCityDeleteForm
)
That's it. Of course you need the Jinja templates. The list method now requires a Jinja template that can handle the columns.
Extending the view class by adding methods
This is all very basic, it is just a start. Assume we want the list function to sort by name, or whatever. We can do this by changing the CRUDView class. We move some code from the list method and add this to a new method 'list__get_items_for_page':
...
def list__get_items_for_page(self, pagination):
items = db_select(
model_class_list=[self.model],
order_by_list=[
(self.model, 'id', 'desc'),
],
limit=pagination.limit,
offset=pagination.offset,
).all()
return items
def list(self, page_number):
...
# get items for page
items = self.list__get_items_for_page(pagination)
...
In the Blueprint we create a new class CityCRUDView that inherits the CRUDView class and add our own 'list__get_items_for_page'. Then we use this this new class to instantiate the city_demo_crud_view object:
class CityCRUDView(CRUDView):
def list__get_items_for_page(self, pagination):
items = db_select(
model_class_list=[self.model],
order_by_list=[
(self.model, 'name', 'asc'),
],
limit=pagination.limit,
offset=pagination.offset,
).all()
return items
city_demo_crud_view = CityCRUDView(
# same as above
...
)
Now the cities are sorted by name instead of id. We can add more methods to the class CRUDView and override them in our Blueprint.
Summary
I am happy that I finally took the time to implement a first version of a CRUD model view class. It took me a few days to put all the pieces together but I am sure that this time was worth it. I already use it in some Blueprints. You can look at the Demo section of this site for a working example.
Links / credits
Base Views
https://flask-appbuilder.readthedocs.io/en/latest/views.html
Customizing Built-in Views
https://flask-admin.readthedocs.io/en/latest/introduction/#customizing-built-in-views
Pluggable Views
https://flask.palletsprojects.com/en/1.1.x/views/
Read more
Flask
Recent
- Hiding database UUID primary keys of your web application
- Don't Repeat Yourself (DRY) with Jinja2
- SQLAlchemy, PostgreSQL, maximum number of rows per user
- Show the values in SQLAlchemy dynamic filters
- Secure data transfer with Public Key encryption and pyNaCl
- rqlite: a high-availability and distributed SQLite alternative
Most viewed
- Using Python's pyOpenSSL to verify SSL certificates downloaded from a host
- Using UUIDs instead of Integer Autoincrement Primary Keys with SQLAlchemy and MariaDb
- Connect to a service on a Docker host from a Docker container
- Using PyInstaller and Cython to create a Python executable
- SQLAlchemy: Using Cascade Deletes to delete related objects
- Flask RESTful API request parameter validation with Marshmallow schemas