angle-up arrow-clockwise arrow-counterclockwise arrow-down-up arrow-left at calendar card-list chat check envelope folder house info-circle pencil people person person-plus phone plus question-circle search tag trash x

Eliminate repetition and improve maintenance by creating a Flask view class

24 March 2020 Updated 25 March 2020 by Peter
In Flask

Using a view class instead of view functions is better because it allows us to share code instead of duplicating and modifying it.

post main image
https://unsplash.com/@ostshem

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

Leave a comment

Comment anonymously or log in to comment.

Comments

Leave a reply

Reply anonymously or log in to reply.