This is the third installment of a tutorial series focusing on how to create a full-stack application using Flask and Stream. Originally, this article was going to cover the creation of the initial database models for your app, as well as handling registration/login and other convenience functions for users, but given the sheer amount of content there is to cover, I’m going to split it into two parts.
This first part is going to cover setting up User models and permissions, and next week we will launch into creating the views and forms for registration and login! Check out the GitHub repo here to help you follow along!
Getting Started
As usual, we’re going to start by installing few packages to get us rolling:
Pip install flask-wtf flask-login flask-mail flask-migrate
Flask-WTF (What The Form) is a form and validation package to aid in the creation of forms, by adding in some extra features like CSRF tokens and alerts.
Flask-Login is a session management package that helps by handling user authentication, allowing us to verify users so that we can pass the correct information to them.
Flask-Mail is an email package that sends out emails to users; we will use it to send confirmations to users upon registration, as well as to aid with some of the convenience functions that I mentioned earlier, like changing a user’s email or password.
Finally, Flask-Migrate is a database versioning tool that lets us keep a record of changes made to our database tables, by handling additions and subtractions of columns from tables.
And By Extension
Since we’ve loaded in new libraries, the first thing we should do is go to the extensions.py
file and update it:
# … from flask_moment import Moment from flask_mail import Mail from flask_login import LoginManager # ... moment = Moment() mail = Mail() login_manager = LoginManager()
In order to initialize the packages in a specific app context when it's created, we will need to include them in our create_app function in the init.py file
.
# … from .extensions import bootstrap, db, moment, login_manager, mail from config import config def create_app(config_name): # … login_manager.init_app(app) mail.init_app(app)
Configure It Out
Given the new packages we’ve installed, our next step will be to edit our config.py
file to set some of their options:
Class Config: # … MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com') MAIL_PORT = int(os.environ.get('MAIL_PORT', '587')) MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in \ ['true', 'on', '1'] MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') SQLALCHEMY_TRACK_MODIFICATIONS = False OFFBRAND_MAIL_SUBJECT_PREFIX = '[Offbrand]' OFFBRAND_MAIL_SENDER = 'Offbrand Admin <admin@offbrand.co>' OFFBRAND_ADMIN = os.environ.get('OFFBRAND_ADMIN') SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_RECORD_QUERIES = True
Now that we have our configuration up, we can put our packages to work!
Next Steps
Our first task is to establish our User and Permissions tables. This section will largely draw on Miguel Grinberg’s Flasky application; if you’re not familiar with its incredible awesomeness, you can check it out here.
Let’s start off by setting up the Users table:
from datetime import datetime import hashlib from werkzeug.security import generate_password_hash, check_password_hash from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from flask import current_app from flask_login import UserMixin, AnonymousUserMixin from . import db, login_manager class User(db.Model, UserMixin): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(64), unique=True, index=True) username = db.Column(db.String(64), unique=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) password_hash = db.Column(db.String(128)) confirmed = db.Column(db.Boolean, default=False) about_me = db.Column(db.Text()) member_since = db.Column(db.DateTime(), default=datetime.utcnow) last_seen = db.Column(db.DateTime(), default=datetime.utcnow) avatar_hash = db.Column(db.String(32)) @property def password(self): raise AttributeError('password is not a readable attribute') @password.setter def password(self, password): self.password_hash = generate_password_hash(password) def verify_password(self, password): return check_password_hash(self.password_hash, password) def generate_confirmation_token(self, expiration=3600): s = Serializer(current_app.config['SECRET_KEY'], expiration) return s.dumps({'confirm': self.id}).decode('utf-8') def confirm(self, token): s = Serializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) except: return False if data.get('confirm') != self.id: return False self.confirmed = True db.session.add(self) return True def ping(self): self.last_seen = datetime.utcnow() db.session.add(self) def gravatar_hash(self): return hashlib.md5(self.email.lower().encode('utf-8')).hexdigest() def gravatar(self, size=100, default='identicon', rating='g'): url = 'https://secure.gravatar.com/avatar' hash = self.avatar_hash or self.gravatar_hash() return '{url}/{hash}?s={size}&d={default}&r={rating}'.format( url=url, hash=hash, size=size, default=default, rating=rating) def __repr__(self): return '<User %r>' % self.username @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id))
Better to Ask Permission
Now that we have a basic User class set up, our next step will be to set up Permissions, so that we can differentiate between our basic users, moderators and administrators. Doing so will let us segment parts of the site based on permission:
#... from . import db, login_manager class Permission: FOLLOW = 1 COMMENT = 2 WRITE = 4 MODERATE = 8 ADMIN = 16 class Role(db.Model): __tablename__ = 'roles' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), unique=True) default = db.Column(db.Boolean, default=False, index=True) permissions = db.Column(db.Integer) users = db.relationship('User', backref='role', lazy='dynamic') def __init__(self, **kwargs): super(Role, self).__init__(**kwargs) if self.permissions is None: self.permissions = 0 @staticmethod def insert_roles(): roles = { 'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE], 'Moderator': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE, Permission.MODERATE], 'Administrator': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE, Permission.MODERATE, Permission.ADMIN], } default_role = 'User' for r in roles: role = Role.query.filter_by(name=r).first() if role is None: role = Role(name=r) role.reset_permissions() for perm in roles[r]: role.add_permission(perm) role.default = (role.name == default_role) db.session.add(role) db.session.commit() def add_permission(self, perm): if not self.has_permission(perm): self.permissions += perm def remove_permission(self, perm): if self.has_permission(perm): self.permissions -= perm def reset_permissions(self): self.permissions = 0 def has_permission(self, perm): return self.permissions & perm == perm def __repr__(self): return '<Role %r>' % self.name class User(db.Model, UserMixin): #... avatar_hash = db.Column(db.String(32)) def __init__(self, **kwargs) super(User, self).__init__(**kwargs) if self.role is None: # check if the new user is set up as the admin, and gives them admin permissions if self.email == current_app.config[‘OFFBRAND_ADMIN’]: self.role = Role.query.filter_by(name=’Administrator’).first() if self.role is None: self.role = Role.query.filter_by(default=True).first() if self.email is not None and self.avatar_hash is None: self.avatar_hash = self.gravatar_hash()
This table and its functions will provide the permissions assignment we need for our application, making sure that our users are only able to access the things they should have access to, and letting us build functions for our moderators and admins to edit questionable content and boot toxic users!
Anon and On
Right now, if a permissions check is done on a user who doesn’t have an account, we’ll get a nasty little error. Our final step in setting permissions is to define a class for anonymous users:
class User(db.Model, UserMixin): #... class AnonymousUser(AnonymousUserMixin): def can(self, permissions): return False def is_administrator(self): return False login_manager.anonymous_user = AnonymousUser @login_manager.user_loader def load_user(user_id): #...
And, with that, this portion of our database modeling is finished (for now). Though, we’ll be back next week to set up user convenience functions, like changing your password or email!
Not Just For Decoration
Now that we have our Permissions table and functions set up, our next step will be to set up a decorator function that checks specific permissions for a route. For this, we will create a decorator.py
file in the app
directory:
from functools import wraps from flask import abort from flask_login import current_user from .models import Permission def permission_required(permission): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if not current_user.can(permission): abort(403) return f(*args, **kwargs) return decorated_function return decorator def admin_required(f): return permission_required(Permission.ADMIN)(f)
The first decorator will allow you to set a specific level of permissions needed to access a route or function, while the second is used to verify that the user has admin-level permissions. I find that this helps to differentiate the look of admin-only resources, as well as to follow Python stylistic guidelines (explicit is better than implicit, etc).
Migration Patterns
Our tables are set, however, this won’t be the last time that we need to change our database; a frequently-used package, called flask-migrate
, will help by versioning changes, allowing us to upgrade and downgrade through different versions, in order to apply or revert those changes. We already installed the package, itself, at the beginning; now we just have to bring it into the application.py
file:
from flask_migrate import flask-migrate #... migrate = Migrate(app, db) @app.shell_context_processor def make_shell_context(): return dict(db=db, User=User, Role=Role, Permission=Permission) @app.cli.command() def deploy(): """Running all of our deployment operations.""" # migrate database to latest revision upgrade() # create or update user roles Role.insert_roles()
While bringing flask-migrate
into the application.py
file, I also took the liberty of updating the shell context to be able to reference the database and models that we have established in models.py, so we don’t have to manually import them every time. To round it out, I created a handy little deploy tool for the command line that will let us quickly upgrade our database to the latest revision and create or update user roles.
Our next step is to initialize the migrate
package for our project, as well as create our initial migration and upgrade the database:
flask db init flask db migrate -m “initial migration” flask db upgrade
Last but not least, we will go into the shell and insert all the roles for our users:
Role.insert_roles() admin_role = Role.query.filter_by(name=’Administrator’).first() default_role = Role.query.filter_by(default=True).first() for u in User.query.all(): if u.role is None: if u.email == app.config[‘OFFBRAND_ADMIN’]: u.role = admin_role else: u.role = default_role db.session.commit()
Time for an Upgrade!
Before we finish this tutorial, I wanted to take a minute to create a landing page for users to be wowed with when they first arrive! Right now, if you were to launch your app, it would still return your basic “Hello World!” text, so a basic html file would be a large upgrade. We’ll start by creating the index.htm
file in app/templates
:
{% block title %}Offbrand{% endblock %} {% block page_content %} <div class=”page-header”> <h1>Welcome to Offbrand!</h1> </div> {% endblock %}
Now that we have the template, we have to reference it in the route (in app/main/views.py
), to have the template rendered:
from flask import render_template #... @main.route(‘/’) def index(): return render_template(‘index.html’)
Flask uses a templating service called jinja2
; if you have developed with Django before it may seem very similar. Jinja allows you to define blocks of code, like your title and page content, as well as import other files and variables from your app.
Sanity Check
To make sure everything is working as it should, we’ll boot up our development server (flask run
) and navigate to localhost.
If you get an ugly error, you might have to set the Flask app, again, with:
set FLASK_APP=application.py
Final Thoughts
With that, we have constructed our database model for users with automatic avatar construction, password setting, and a complete permissions system with anonymous users! Additionally, we have covered initializing Flask-Migrate to track changes to our models, and, in our next installment, we will get into generating and validating confirmation tokens that we will send in an email when users sign up, along with some new user routes for our authentication and some basic navigation.
Thanks for reading and happy coding!
Note: The next post in this series can be found here.