Series: Building a Social Network with Flask & Stream – Part 3

Spencer P.
Spencer P.
Published January 24, 2020 Updated February 5, 2020

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:

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
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.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->