Welcome to part 7 of this series, in this part we’re going to build an admin dashboard for our application which would provide basic CRUD functionality for the models in our database.

Go ahead and install flask admin from pip

pip install flask-admin

It’s very easy to use flask-admin, just import the Admin class, create a new object with it, and pass in our flask application object as the first parameter, the name argument is the name that’s displayed on the admin homepage, it defaults to the application name.

After that you can add different models by by creating ModelView objects.

Our Model objects would show up as different menus in the admin dashboard.

Add the following to votr.py

from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView

admin = Admin(votr, name='Dashboard')
admin.add_view(ModelView(Users, db.session))

reload the page and open localhost:5000/admin

You should see a a blank page that tells you Your “Login was successful” and a plain navbar that looks like this

flask-7-navbar

You can add your models and play around the the admin. create, delete and modify records as you please

Adding authentication to flask admin

After playing around with flask admin, you’d have noticed that something very important is missing…Authentication

Now, there are several ways of adding authentication to flask admin, but I’m going to show you how to integrate our current Authentication system with flask admin.

First of all, it would make more sense to keep all our “admin stuff” in a separate file in order to keep the main application file clean. Let’s call that admin file admin.py

Flask admin provides a way of “hiding” models from users based on any condition we like, this is very useful in building role-based admin interfaces, For example a cashier might have access to the post transactions but he should not be able to see the total transactions posted for the day (Which contains other entries posted by other cashiers), Only the manager should be able to see that

To add this feature, we have to inherit the ModelView class and override two methods

place the following in the admin.py file

from flask_admin.contrib.sqla import ModelView
from flask import session, redirect, url_for, request


class AdminView(ModelView):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.static_folder = 'static'

    def is_accessible(self):
        return session.get('user') == 'Administrator'

    def inaccessible_callback(self, name, **kwargs):
        if not self.is_accessible():
            return redirect(url_for('home', next=request.url))

The is_accessible method determines if a model is accessible or not, based on a particular condition, in our case we only want to show the model to the current user, if they’re logged in and their username equals “Administrator”

In the inaccessible_callback method, we’re simply saying if the model is not accessible redirect the user back to the homepage. The next argument is used to take the admin user back to the last page they tried to access.

To make use of this parameter, just change the return statement in your login route to:

return redirect(request.args.get('next') or url_for('home'))

and the action url of the login form on the homepage:

<form method="post" action="{{ url_for('login', next=request.args.get('next')) }}">


To use our AdminView class, we have to tell Flask-Admin about it in votr.py.

from flask_admin import Admin
from admin import AdminView

votr = Flask(__name__)

# load config from the config file we created earlier
votr.config.from_object('config')

# create the database
db.init_app(votr)
# db.create_all(app=votr)

migrate = Migrate(votr, db, render_as_batch=True)

admin = Admin(votr, name='Dashboard', index_view=AdminView(Topics, db.session, url='/admin', endpoint='admin'))
admin.add_view(AdminView(Users, db.session))
admin.add_view(AdminView(Polls, db.session))
admin.add_view(AdminView(Options, db.session))

The important line here is:

admin = Admin(votr, name='Dashboard', index_view=AdminView(Topics, db.session, url='/admin', endpoint='admin'))

with the index_view argument, we’re telling flask admin that we want the Topics model to serve as the default view for our application instead of the blank homepage you saw earlier, the url parameter changes the url for the topic model to /admin instead of the default which was /admin/topics

Finally we’re setting the endpoint to admin

That’s all!, with this information flask admin is able to build the blueprints with our models (Internally flask admin uses introspection to get the details about the models passed to it and creates blueprints on the fly)

The admin page should also be protected from users whose username doesn’t equal Administrator. Our username field is actually unique, so this means that we can only have one admin user.

To create other admin users, you can add more usernames to check for in the is_accessible method or better still add a boolean field to your user model to determine if the user is an admin user or not. I’ll leave you to your imagination here.


Protecting the polls from multiple votes

In the last part, we talked about adding a new feature that prevents users from voting multiple times on a poll. To do this we’ll have to add a new model to track a user and the polls they’ve has voted on, and then on every request to /api/vote we can simply check if they voted on that poll before and then abort the request with a custom message or allow the request if they haven’t.

Add a new model called UserPolls in votr.py

class UserPolls(Base):

    topic_id = db.Column(db.Integer, db.ForeignKey('topics.id'))
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))

    topics = db.relationship('Topics', foreign_keys=[topic_id],
                             backref=db.backref('voted_on_by', lazy='dynamic'))

    users = db.relationship('Users', foreign_keys=[user_id],
                            backref=db.backref('voted_on', lazy='dynamic'))

after doing that run the migrations and upgrade the database

IMPORTANT: before running the migrations, make sure you comment out the db.create_all in votr.py if you don’t SQLAlchemy, would create the new table from you and Alembic won’t be able to detect any change in the database schema when you try to run the database migration

Modify the /api/vote endpoint to incorporate the new changes

@votr.route('/api/poll/vote', methods=['PATCH'])
def api_poll_vote():
    poll = request.get_json()

    poll_title, option = (poll['poll_title'], poll['option'])

    join_tables = Polls.query.join(Topics).join(Options)

    # Get topic and username from the database
    topic = Topics.query.filter_by(title=poll_title).first()
    user = Users.query.filter_by(username=session['user']).first()

    # filter options
    option = join_tables.filter(Topics.title.like(poll_title)).filter(Options.name.like(option)).first()

    # check if the user has voted on this poll
    poll_count = UserPolls.query.filter_by(topic_id=topic.id).filter_by(user_id=user.id).count()
    if poll_count > 0:
        return jsonify({'message': 'Sorry! multiple votes are not allowed'})

    if option:
        # record user and poll
        user_poll = UserPolls(topic_id=topic.id, user_id=user.id)
        db.session.add(user_poll)

        # increment vote_count by 1 if the option was found
        option.vote_count += 1
        db.session.commit()

        return jsonify({'message': 'Thank you for voting'})

    return jsonify({'message': 'option or poll was not found please try again'})

That’s all, a user shouldn’t be able to vote on a poll more than once, if they try that they should see a popup in their browser telling them that “multiple votes are not allowed”.


Customizing Flask-Admin

Out of the box, flask admin gives us a pretty usable interface and reasonable defaults, but that doesn’t mean it’s not flexible. flask admin allows us make different enhancements ranging from ui enhancements (display order, theming) to behavioural enhancements. (search boxes, filters).

We’re going to add some new features to the admin page:

  • Change the order in which the columns are displayed for the Topics model
  • A search box to search for poll by their title
  • A filter on the status field so we can see all open or closed polls at any point in time
  • Make the date field for Topics sortable
  • Change the default date format to something more readable
  • Display the total vote count for each topic and make the topics sortable with it


The first four options added easily

class TopicView(AdminView):

    def __init__(self, *args, **kwargs):
        super(TopicView, self).__init__(*args, **kwargs)

    column_list = ('title', 'date_created', 'date_modified', 'status')
    column_searchable_list = ('title',)
    column_default_sort = ('date_created', True)
    column_filters = ('status',)

column_list: This is used to list the various columns in the order you want them.

column_searchable_list: Columns that you want to be searchable in the model.

column_default_sort: The column that the view should be sorted with by default (when the view is loaded for the first time). The second parameter True tells flask-admin to sort it in descending order.

column_filters: List of columns that can be used to filter.

Notice that we created a new class TopicView that inherits from AdminView, so let’s change the index_view argument for the Admin object in votr.py to use the new class.

# new top level imports
from admin import AdminView, TopicView


admin = Admin(votr, name='Dashboard', index_view=TopicView(Topics, db.session, url='/admin', endpoint='admin'))

That’s it, reload the admin page and you should see the new changes in effect

flask-7-customize1

With some simple variables, we’ve been able to add some customization to our admin interface.

The last two customizations are not as straight-forward, but they aren’t too complex either

The following code is used to change the date format

from flask_admin.contrib.sqla import ModelView
from flask import session, redirect, url_for, request
from flask_admin.model import typefmt
from datetime import datetime

class AdminView(ModelView):

    def __init__(self, *args, **kwargs):
        super(AdminView, self).__init__(*args, **kwargs)
        self.static_folder = 'static'

        self.column_formatters = dict(typefmt.BASE_FORMATTERS)
        self.column_formatters.update({
                type(None): typefmt.null_formatter,
                datetime: self.date_format
            })

        self.column_type_formatters = self.column_formatters

    def date_format(self, view, value):
          return value.strftime('%B-%m-%Y %I:%M:%p')

We put it in the constructor of AdminView because we want to re-use this date format in all our models including Topics which already inherits from AdminView

You should also note that we can define flask admin variables as class variables like we did for the first four customizations or as instance variables like we’re doing with self.column_type_formatters. so if you want other models to inherit a customization you can create the property as an instance variable.

column_formatters expects a dictionary of Object types as the key and the corresponding display value we want as the value

typefmt.BASE_FORMATTERS provides sane default formats for the various types of fields our model has. This list includes formatters for lists, dicts, bools and other python data types.

We simply updated the dictionary with our own custom format for datetime objects and left the rest to flask admin to handle.

To accomplish the last improvement, We have to make use of a feature in SQLAlchemy called hybrid_property

A hybrid_property can be thought of as a computable column. At the database layer, the column doesn’t exist but we can use it almost like any other field in our model.

Let’s modify the Topics model in the models.py file

# new top level imports
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import select, func

# Model for poll topics
class Topics(Base):
    title = db.Column(db.String(500))
    status = db.Column(db.Boolean, default=1)  # to mark poll as open or closed
    create_uid = db.Column(db.ForeignKey('users.id'))

    created_by = db.relationship('Users', foreign_keys=[create_uid],
                                 backref=db.backref('user_polls', lazy='dynamic'))

    # user friendly way to display the object
    def __repr__(self):
        return self.title

    # returns dictionary that can easily be jsonified
    def to_json(self):
        return {
                'title': self.title,
                'options': [{'name': option.option.name, 'vote_count': option.vote_count}
                            for option in self.options.all()],
                'status': self.status,
                'total_vote_count': self.total_vote_count
            }

    @hybrid_property
    def total_vote_count(self, total=0):
        for option in self.options.all():
            total += option.vote_count

        return total

    @total_vote_count.expression
    def total_vote_count(cls):
        return select([func.sum(Polls.vote_count)]).where(Polls.topic_id == cls.id)

The hybrid_property decorator tells SQLAlchemy that we want to use the return value of total_vote_count as a column of the model not just a plain property of the class

The key part of the hybrid_property is the the decorator @total_vote_count.expression. Depending on how complex the hybrid_property is and how we compute the values, we might need a hybrid_property expression (For simple computed values based on fields of the model it’s not necessary, flask-admin can figure out how to sort the column)

The expression must return SQL that would be used by flask-admin to sort the column properly, if you don’t need the sorting you can just leave out the expression part.

So in this case we’re returning SQL that’s similar to this:

SELECT sum(polls.vote_count) AS sum_1 FROM polls, topics WHERE polls.topic_id = topics.id

So with that information, flask-admin would be able to sort the Topics by the total number of votes they have.

Don’t forget to add this to the TopicView class in admin.py

column_sortable_list = ('total_vote_count',)

and modify colum_list to show the new column

column_list = ('title', 'date_created', 'date_modified', 'total_vote_count', 'status')

Note: The to_json method of the model has been modified to use the new hybrid property


We’ve come to the end of this part, this post was a gentle introduction to flask-admin and what you can do with it. you also saw how you can restrict each user to a single vote on a poll.

Everything about flask admin is customizable, to keep this tutorial brief i didn’t talk about customizing the templates but if you’re wondering how “customizable” flask admin templates are here are two screenshots of an admin dashboard built with flask admin.

flask admin dashboard

flask admin dashboard2

There’s a so much to explore. Redis CLI is even integrated with flask admin!.

Now that you know about flask admin, try to resist the urge to use it for your general/public user’s admin dashboard. If you know 101% of what you’re doing, there’s actually nothing wrong with using it, but have it at the back of your head that you’ll probably spend more time customizing flask admin compared to the time you’ll spend rolling your own admin dashboard from scratch.

You also run the risk of users gaining access or seeing things they aren’t supposed to see.

Flask admin is more suitable for admins or users that you can trust

In the next part, we’re going to talk about flask blueprints, when and why you should use them in your flask application. see you there!