Creating a Blog Application Using Flask Framework

Introduction

Flask is a Python micro web framework. It is designed to make getting started quick and easy, with the ability to scale up to complex applications. It has no database abstraction layer, form validation, or any other components where pre-existing third-party libraries provide common functions hence it being a "micro" framework. It gives developers flexibility and it aims to keep projects simple but extensible.

In this tutorial, we'd be creating a simple blog application using Flask and other third-party packages.

Prerequisites

This tutorial requires basic knowledge of the following;

  • Python 3

  • Flask

  • HTML

Installation

Install Python from the official Python website

To get started, create a virtual environment and activate it so we can install the libraries necessary only for this tutorial.

Run the following commands on your terminal.

$ mkdir Bloggr
$ cd Bloggr
~/Bloggr $ python -m venv v_env
~/Bloggr $ source v_env/bin/activate

Now with the virtual environment activated, we can install Flask and some Flask extensions using Python's package manager, pip .

(v_env) ~/Bloggr $ pip install flask flask-migrate flask-sqlalchemy flask-login flask-wtf bootstrap-flask

Creating a Flask App

The next step is to determine the layout of our project.

First, create a main.py file in the project's root directory. It's in this file we are going to create our application by instantiating the Flask class.

from flask import Flask

app = Flask(__name__)

To run the application, open your terminal and run this command

(v_env) ~/Bloggr $ flask --app main --debug run

You'd see an output on the terminal that's similar to this

* Serving Flask app 'main'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 782-141-163

You can now access the application through your browser via the URL http://127.0.0.1:5000

To stop the server, press CTRL + C .

Setting up the Database

Next, we configure the app. Create a new file, app_conf.py and pass in the following key-value pairs. These pairs include settings for the database, database URI, secret key, etc.

from pathlib import Path

base_dir = Path(__file__).resolve().parent
DATABASE = "database/bloggr.db"
ENV = "development"
SQLALCHEMY_DATABASE_URI = f"sqlite:///{Path(base_dir).joinpath(DATABASE)}"
SECRET_KEY = "safe-space"
BOOTSTRAP_BOOTSWATCH_THEME = "zephyr"

Note the BOOTSTRAP_BOOTSWATCH_THEME variable. The bootstrap-flask extension uses this variable to set the bootstrap theme we want to zephyr which would improve the design and styling of our application. We need to register the application object with the Bootstrap5 class for it to work.

We now import this file (app_conf.py) into the main.py file as a module and pass it to the application object as a configuration file.

import app_conf
from flask import Flask
from flask_bootstrap import Bootstrap5

app = Flask(__name__)
app.config.from_object(app_conf)
bootstrap = Bootstrap5(app)

Create a database folder, enter into the folder and create a file db.py. Import SQLAlchemy class from flask_sqlalchemy and instantiate it to create a database object.

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

Import the database object into the main.py and register it with the app.

import app_conf
from flask import Flask

from database.db import db

app = Flask(__name__)
app.config.from_object(app_conf)
db.init_app(app)

@app.before_first_request
def create_database():
    db.create_all()

The decorator @app.before_first_request and function create_database() creates all the required tables in the database before the app receives its first request.

Blogs

Now that the database is set, we can begin defining the model for blogs, forms and write view functions that respond to requests to the application. Create a folder blogs to contain everything related to blogs.

Models

A model class is similar to a database table. This model class would define all the columns in a table for each blog post that would be stored in the database.

Enter into the blogs directory and create a file models.py then write the following code.

from datetime import datetime

from database.db import db


class Blog(db.Model):
    id = db.Column(db.Integer, unique=True, primary_key=True)
    title = db.Column(db.String(80), unique=True, nullable=False, index=True)
    content = db.Column(db.Text(5000), nullable=False)
    date_posted = db.Column(db.DateTime(timezone=True), default=datetime.now)
    tag = db.Column(db.String(80), nullable=True, index=True)
    user_id = db.Column(
        db.Integer,
        db.ForeignKey("user.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    blog_comments = db.relationship(
        "Comment",
        backref="comments",
        cascade="all, delete", 
        lazy=True, 
        order_by="Comment.date_posted.desc()"
    )

    def __repr__(self):
        return f"{self.title} by {self.writer}"


class Comment(db.Model):
    id = db.Column(db.Integer, unique=True, primary_key=True)
    comment = db.Column(db.Text(800), nullable=False)
    blog_id = db.Column(db.Integer, db.ForeignKey("blog.id", 
    ondelete="CASCADE"), nullable=False, index=True)
    user_id = db.Column(
        db.Integer,
        db.ForeignKey("user.id", ondelete="CASCADE"),
        nullable=True,
        index=True
    )
    guest_user = db.Column(db.String, nullable=True)
    date_posted = db.Column(db.DateTime(timezone=True), default=datetime.now)

    def __repr__(self):
        return f"{self.comment}"

    @staticmethod
    def add_comment(blog, comment, guest_user=None, user_id=None):
        new_comment = Comment(blog_id=blog.id, comment=comment, 
            guest_user=guest_user, user_id=user_id)
        db.session.add(new_comment)
        db.session.commit()

In the models.py, there are two model classes Blog and Comment for blogs and comments respectively. The blog model(table) contains columns for blog ID, title, content, date it was posted, ID of the user that posted the blog, tag(s) and comments made under the blog. We do the same thing for the comment model although we added a static method for the comment which we would use later.

Forms

To be able to add or edit blogs or even post comments, we need to define forms for blogs and comments. These forms would be used in view functions and rendered in the templates. The form would contain fields necessary to make a blog (title, content, tags) or a comment. The forms would be defined in a file forms.py .

from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField, RadioField
from wtforms.validators import DataRequired, Length, ValidationError, InputRequired


class BlogForm(FlaskForm):
    title = StringField(
        "Title", validators=[DataRequired("Title cannot be empty")]
    )
    content = TextAreaField(
        "Content", validators=[DataRequired("Content cannot be empty")]
    )
    tag = StringField(
        "Tag",
        validators=[InputRequired()],
        render_kw={"placeholder": "e.g. NSFW, Sports, Programming, Music"},
    )
    submit = SubmitField("Submit post")

    def validate_title(self, field: StringField):
        word_count = field.data.split()
        if 2 > len(word_count) or len(word_count) > 15:
            raise ValidationError("Title should be between 2 and 15 words long")

    def validate_content(self, field: StringField):
        word_count = field.data.split()
        if not len(word_count) >= 2:
            raise ValidationError("Content cannot be less than 200 words")


class CommentForm(FlaskForm):
    comment_as = RadioField(
        "Comment as", validators=[InputRequired()], choices=("Guest", "Member - requires login")
    )
    name = StringField(label="Name", validators=[DataRequired("Name cannot be empty")])
    comment = TextAreaField("Comment", validators=[DataRequired(), Length(2, 500)])
    submit = SubmitField("Post comment")

The BlogForm and CommentForm both inherit from FlaskForm class and each class declare variables which are the form fields. We also import validators which would validate the data on submission of each of the forms. The two methods validate_title() and validate_content() check the length of the title and content respectively and ensures it's within the specified range else a validation error is raised.

Views and Blueprints

Create a new file views.py where we are going to write our view functions. These view functions are functions that would be executed by the application in response to requests and these functions are mapped to a URL route. They include functions to retrieve all blogs, retrieve a single blog, add a blog, edit a blog and delete a blog.

A Blueprint is a way to organize a group of related views and other codes. Rather than registering views and other code directly with an application, they are registered with a blueprint and then registered with the application.

from gettext import ngettext  

from flask import Blueprint, flash, redirect, render_template, request, url_for 
from flask_login import current_user, login_required

from database.db import db  

from .forms import BlogForm, CommentForm 
from .models import Blog, Comment

blogs_bp = Blueprint("blogs", __name__)

This creates a new blueprint blogs . Like the application object, the blueprint needs to know where it’s defined, so __name__ is passed as the second argument.

Import and register the blueprint in the main.py file using app.register_blueprint()

import app_conf
from flask import Flask

from blogs.views import blogs_bp
from database.db import db

app = Flask(__name__)
app.config.from_object(app_conf)
app.register_blueprint(blogs_bp) 
db.init_app(app)

@app.before_first_request
def create_database():
    db.create_all()

The first view is home_page() . When a user visits the URL route mapped to this function, it would render a template (which we would write later) that displays all the blogs from the database in descending order. We can also include search functionality for users to be able to search for certain blogs.

@blogs_bp.route("/", methods=["GET"])
def home_page():
    blogs = db.session.query(Blog).order_by(Blog.date_posted.desc()).all()
    context = {"blogs": blogs, "user": current_user}
    query = request.args.get("search")
    if query:
        results = []
        message = "No blogs matched your search, Try typing something different"
        for blog in blogs:
            if query.lower() in blog.title.lower():
                results.append(blog)
                text = ngettext("result", "results", len(results))
                message = f"{len(results)} {text} found"
        flash(message, "info")
        context = {"blogs": results}
    return render_template("all_blogs.html", **context)

The flash() function "flashes" a message to the next request. The first argument is the message to be displayed and the second argument is the category of the message. We would use it in every other view.

The get_single_blog() view, just like the home_page() renders a template when a user visits the URL route mapped to the view but in this case, it returns just a single blog and users can post comments (either as guests to the site or as registered members) under the blog by filling out and submitting the comment form. The ID of the blog a user wants to view is passed to the function as an argument.

@blogs_bp.route("/blogs/<int:blog_id>/", methods=["GET", "POST"])
def get_single_blog(blog_id):
    blog = db.get_or_404(Blog, blog_id)
    form = CommentForm()
    if form.validate_on_submit():
        name = form.name.data
        comment = form.comment.data
        comment_as = form.comment_as.data
        if not current_user.is_authenticated and comment_as != "Guest":
            flash("You need to login to post a comment as a Member", "warning")
            return redirect(url_for("auth.log_in_user", next=f"/blogs/{blog_id}/"))
        elif comment_as == "Guest":
            Comment.add_comment(blog, comment, guest_user=name)
        else:
            Comment.add_comment(blog, comment, user_id=current_user.id)
        flash("Comment posted successfully", "success")
        return redirect(url_for(".get_single_blog", blog_id=blog_id))
    context = {"blog": blog, "form": form}
    return render_template("blog.html", **context)

This is also where we make use of the static method we earlier defined in the Comment model. Using this method, we can easily check how a user intends to comment, whether as a guest or as a member(requires the user to be logged in to their account) then save the comment to the database without complications.

To add a blog, define a view function add_blog() where we instantiate the BlogForm. When the form is filled, submitted and validated, a Blog object will be created and populated with data from the BlogForm and then committed to the database.

@blogs_bp.route("/add_new_blog/", methods=["GET", "POST"])
@login_required
def add_blog():
    form = BlogForm()
    if form.validate_on_submit():
        new_blog = Blog()
        form.populate_obj(new_blog)
        new_blog.writer = current_user
        db.session.add(new_blog)
        db.session.commit()
        flash("Blog posted successfully", "success")
        return redirect(url_for(".home_page"))
    return render_template("add_blog.html", form=form)

Another is edit_blog() view to allow a writer to edit their blog. It is similar to add_blog() view except that the BlogForm is prepopulated with the blog to be edited and the ID of the blog to be edited is also passed as an argument to the view function, the same also goes for the delete_blog() view. Only the writer of the blog can make changes to the blog and the changes made are committed to the database. The delete_blog() view is for deleting blogs which just like the edit_blog() view, allows only the writer of a blog to delete the blog and every comment made under it.

The @login_required decorator is used to ensure that a view is accessible to logged-in users only.

@blogs_bp.route("/blogs/<int:blog_id>/edit_blog/", methods=["GET", "POST"])
@login_required
def edit_blog(blog_id):
    blog = db.get_or_404(Blog, blog_id)
    form = BlogForm(obj=blog)
    if blog.writer != current_user:
        return abort(403, "You don't have permission to perform this action")
    if form.validate_on_submit():
        form.populate_obj(blog)
        db.session.commit()
        flash("Update was saved successfully", "success")
        return redirect(url_for(".get_single_blog", blog_id=blog_id))
    context = {"blog": blog, "form": form}
    return render_template("edit_blog.html", **context)


@blogs_bp.route("/blogs/<int:blog_id>/delete_blog/", methods=["GET", "POST"])
@login_required
def delete_blog(blog_id):
    blog = db.get_or_404(Blog, blog_id)
    if blog.writer != current_user:
        return abort(403, "You don't have permission to perform this action")
    if request.method == "POST":
        db.session.delete(blog)
        db.session.commit()
        flash(f"Your blog has been deleted", "warning")
        return redirect(url_for(".home_page"))
    return render_template("delete_blog.html", blog=blog)

Templates

Our next focus is to write templates for each of the views we just defined. If you run the server and go to any of the URLs we mapped to the views, you'd get a TemplateNotFound error because we called render_template() in the views without writing the template files. Templates are files that contain static data as well as placeholders for dynamic data. We will use these templates to render HTML which will be displayed in the browser.

First, we need to write a base template from which all other templates will inherit. it will give us the ability to reuse the same HTML code without having to repeat it each time it is needed.

In the project's root directory, create a new folder templates , enter the templates folder and create a file base.html . Type this code into that file.

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/static/style.css">
    <title>Bloggr</title>
    {% block styles %}
        {{ bootstrap.load_css() }}
    {% endblock %}
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark py-2 bg-info">
        <div class="container-fluid">
            <a class="navbar-brand" href="/about/">Bloggr{{ render_icon('emoji-smile') }}</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                    <li class="nav-item">
                        <a class="nav-link active me-3" aria-current="page" href="/">Home</a>
                    </li>
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle active" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                        {% if current_user.is_authenticated %}
                            Hello, {{ current_user|title }}
                        {% else %}
                            Account
                        {% endif %}
                        </a>
                        <ul class="dropdown-menu" aria-labelledby="navbarDropdown">
                            {% if current_user.is_authenticated %}
                                <li><a class="dropdown-item px-5" href="{{ url_for('users.get_user', user_id=current_user.id )}}">My account</a></li>
                                <li><hr class="dropdown-divider"></li>
                                <li>
                                    <form action="{{ url_for('auth.log_out_user') }}" method="post">
                                        <input class="log_out" type="submit" value="Log out">
                                    </form>
                                </li>
                            {% else %}
                                <li><a class="dropdown-item" href="{{ url_for('auth.log_in_user') }}">Log In</a></li>
                                <li><hr class="dropdown-divider"></li>
                                <li><a class="dropdown-item" href="{{ url_for('auth.register') }}">Register</a></li>
                            {% endif %}
                        </ul>
                    </li>
                    {% if current_user.is_authenticated %}
                        <li class="nav-item">
                            <a class="nav-link active" href="{{ url_for('blogs.add_blog') }}">Add Blog</a>
                        </li>
                    {% endif %}
                </ul>
                <span class="nav-item">
                    <a class="nav-link text-white" href="{{ url_for('contact_page') }}" tabindex="-1">Contact Us</a>
                </span>
                <span class="nav-item">
                    <a class="nav-link text-white" href="{{ url_for('about_page') }}" tabindex="-1">About</a>
                </span>
                <form action="{{ url_for('blogs.home_page') }}" method="get" class="d-flex">
                    <input class="form-control mt-1 p-2" type="search" name="search" placeholder="Search for blogs" aria-label="Search">
                </form>
            </div>
        </div>
    </nav>
    <div class="content">
        <div class="body">
            {% block body %} {% endblock %}
        </div>
    </div>

    {% block scripts %}
        {{ bootstrap.load_js() }}
    {% endblock %}
</body>
</html>

There are two blocks defined here but only one would be overridden in other templates.

  1. {% block scripts %} In this block, we call two helper functions bootstrap.load_css() and bootstrap.load_js() which loads bootstrap resources in the template.

  2. {% block body %} As we said in the meaning of templates, this block serves as a "placeholder for dynamic data". It will change the content displayed on the page. This block would be overridden in other templates as each page would have different content to display.

The render_icon() helper function renders a bootstrap icon, a smiling emoji in this case. We would make use of functions like this to render other bootstrap resources.

Create another file home_page.html which will serve as the template for the home_page() view and type the following code.

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon, render_messages %}

{% block body %}
<p>{{ render_messages(dismissible=True, dismiss_animate=True) }}</p>
{% if not blogs %}
    <h3 class="border-0">There are currently no blogs yet.</h3>
    {% if current_user.is_authenticated %}
        <h5><a href="{{ url_for('blogs.add_blog') }}">Add a Blog</a></h5>
    {% else %}
        <h5><a href="{{ url_for('auth.register') }}">Create an Account</a> and add a blog</h5>
    {% endif %}
{% else %}
    <div class="blog_container">
        {% for blog in blogs %}
            <article class="my-5">
                {{ render_icon('cursor-fill', '1rem') }}<a href="{{ url_for('blogs.get_single_blog', blog_id=blog.id) }}"> {{ blog.title|title }} </a> <br>
                <div class="mx-4">
                    <span class="blog_post">  {{ blog.content.split()[:35]|join(' ')|safe }} ... </span> <br> <br>
                    <span class="blog_details text-muted"> {{ render_icon('tag', '1.5rem') }} {{ blog.tag }} <br>
                        Posted: {{ blog.date_posted.date() }} </span> <br>
                </div>
            </article>
        {% endfor %}
    </div>
{% endif %}
{% endblock %}

{% extends 'base.html' %} is called the "extends tag" and should always be the first tag in any template. It tells Jinja that this template extends the base template we wrote earlier. All the rendered content must appear inside {% block %} tags that override blocks from the base template. Just like before, we use the helper functions render_icon() and render_messages() to render bootstrap icons and messages (from the flash() function we called in the view).

For the get_single_blog() view, the render_form() helper function is used to render the comment form and if the blog already has comments, we use a for loop to iterate over all the comments and display them on the page. Create a file single_blog.html and type the code.

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon, render_messages %}
{% from 'bootstrap5/form.html' import render_form %}

{% block body %}
<p>{{ render_messages(dismissible=True, dismiss_animate=True) }}</p>
<h3>{{ blog.title|title }}</h3>
<div class="blog_container">
    <div class="blog_details text-muted">
        {{ render_icon('tag', '1.5rem') }} {{ blog.tag }} <br>
        {{ blog.date_posted.date().strftime('%Y %b %d') }} <br>
        By <a href="{{ url_for('users.get_user', user_id=blog.writer.id) }}">{{ blog.writer }}</a> <br>
    </div>
    <article class="my-5 blog_post">{{ blog.content|safe }}</article>
    {% if current_user == blog.writer %}
        <div class="options">
            <a class="btn btn-outline-secondary p-2 me-5" href="{{ url_for('blogs.edit_blog', blog_id=blog.id) }}">Edit Blog</a>
            <a class="btn btn-outline-danger p-2" href="{{ url_for('blogs.delete_blog', blog_id=blog.id) }}">Delete Blog</a>
        </div>
    {% endif %}
    {% if blog.blog_comments %}
        <hr>
        <div>
            <h5>Comments</h5>
            {% for comment in blog.blog_comments %}
                <div class="comments">
                    {% if comment.posted_by %}
                        {{ comment.posted_by }}:
                    {% else %}
                        {{ comment.guest_user }}:
                    {% endif %}
                    {{ comment }}<br>
                </div>
            {% endfor %}
        </div>
    {% endif %}
    <hr>
    <div>
        <h5>Add Comment</h5>
        {{ render_form(form) }}
    </div>
</div>
{% endblock %}

{% if current_user == blog.writer %} checks if the current user is also the writer of the blog, and if so, the edit button and delete button would be made available to the user to either edit or delete the blog.

In the templates for add_blog() view and edit_blog() view, we simply render the blog form but for delete_blog() , only a delete button is displayed for the writer to delete the blog.

templates/add_blog.html

{% extends 'base.html' %}
{% from 'bootstrap5/form.html' import render_form %}
{% from 'bootstrap5/utils.html' import render_icon %}

{% block body %}
<h3>Add Blog</h3>
{{ render_form(form, extra_classes='blog_form') }}  
{% endblock %}

templates/edit_blog.html

{% extends 'base.html' %}
{% from 'bootstrap5/form.html' import render_form %}
{% from 'bootstrap5/utils.html' import render_icon %}

{% block body %}
<h3>Edit Blog</h3>
{{ render_form(form, extra_classes='blog_form') }}  
{% endblock %}

templates/delete_blog.html

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon %}

{% block body %}
<h3> Delete Blog</h3>
<h5>This action will also delete comments made by other users under this blog</h5>
<div class="blog_container my-5">
    <span>Title: {{ blog.title }}</span> <br>
    <span>Posted: {{ blog.date_posted.date().strftime('%Y %b %d') }}</span> <br>
    <span>By: {{ blog.writer }}</span>
    <div>
        <article class="my-3 blog_post">
            {% if blog.content.split()|length > 50 %}
                {{ blog.content.split()[:50]|join(' ')|safe }} + {{ blog.content.split()|length - 50 }} more words...
            {% else %}
                {{ blog.content }}
            {% endif %}
        </article>
    </div>
    <form action="" class="form" method="post">
        <div>
            <input class="btn btn-danger me-5" type="submit" value="Delete">
            <a id="cancel" class="btn btn-outline-secondary" href="{{ url_for('blogs.get_single_blog', blog_id=blog.id) }}">Cancel</a>
        </div>
    </form>
</div>
{% endblock %}

Users

In this step, we are going to handle user accounts and most of the code in this step is very similar to what we've already done in the blogs with few differences. Create a new folder users for this stage.

Models

For every user that registers on our application, we would want to save their ID, first name, last name, email address, password and about (bio) therefore we are going to define a model class that saves each of them as columns in the user table in the database. We would also make use of these details when defining the registration form for authentication.

from flask_login import UserMixin

from blogs.models import Blog, Comment
from database.db import db


class User(db.Model, UserMixin):
    id = db.Column(db.Integer, unique=True, primary_key=True)
    first_name = db.Column(db.String(20), nullable=False)
    last_name = db.Column(db.String(20), nullable=False)
    email = db.Column(db.String(20), unique=True, nullable=False)
    about = db.Column(db.Text(100), nullable=False)
    password = db.Column(db.String(50), unique=True, nullable=False)
    blogs = db.relationship(
        Blog,
        backref="writer",
        cascade="all, delete",
        lazy=True,
        order_by=Blog.date_posted.desc(),
    )
    comments = db.relationship(Comment, backref="posted_by", lazy=True)

    def __repr__(self):
        return self.get_full_name()

    def get_full_name(self):
        return f"{self.first_name} {self.last_name}"

The user model also inherited UserMixin class from flask_login and according to the documentation, this class provides default implementations for the methods that Flask-Login expects user objects to have. Also, the blog and comment column represents a backward relationship to blogs and comments made by a user. We also define a get_full_name() function that returns the user's full name.

Forms

This is what our user form would like in forms.py .

from flask_wtf import FlaskForm
from wtforms import EmailField, StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Email


class UserForm(FlaskForm):
    first_name = StringField("First Name", validators=[DataRequired()])
    last_name = StringField("Last Name", validators=[DataRequired()])
    email = EmailField("Email Address", validators=[DataRequired(), Email()])
    about = TextAreaField("About Yourself", validators=[DataRequired()])
    submit = SubmitField("Update")

Views

Just like we already did before with the blog's views, we'd create a user blueprint and also register it with the application object in the main.py file.

from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required

from database.db import db

from .forms import UserForm
from .models import User

users_bp = Blueprint("users", __name__, url_prefix="/users/")

If you noticed, we passed a url_prefix argument when creating the user blueprint. Its value, /users/ will be prepended to all the URLs associated with this blueprint.

import app_conf
from flask import Flask

from blogs.views import blogs_bp
from users.views import users_bp
from database.db import db

app = Flask(__name__)
app.config.from_object(app_conf)
app.register_blueprint(blogs_bp) 
app.register_blueprint(users_bp) 
db.init_app(app)

@app.before_first_request
def create_database():
    db.create_all()

Next, we are going to define view functions to get a single user, update a user's details and delete a user and we have to pass in the ID of the user to be retrieved, edited or deleted as an argument to their respective view functions.

@users_bp.route("/<int:user_id>/", methods=["GET", "POST"])
def get_user(user_id):
    user = db.get_or_404(User, user_id)
    return render_template("user_page.html", user=user)


@users_bp.route("/<int:user_id>/update_my_account/", methods=["GET", "POST"])
@login_required
def update_user_account(user_id):
    user = db.get_or_404(User, user_id)
    form = UserForm(obj=user)
    if user != current_user:
        return abort(403, "You do not have permission to perform this action.")
    if form.validate_on_submit():
        user.first_name = form.first_name.data
        user.last_name = form.last_name.data
        user.email = form.email.data
        user.about = form.about.data
        db.session.commit()
        flash("Update was saved successfully", "success")
        return redirect(url_for(".get_user", user_id=user_id))
    context = {"form": form, "user": user}
    return render_template("update_user.html", **context)


@users_bp.route("/<int:user_id>/delete_account/", methods=["GET", "POST"])
@login_required
def delete_user_account(user_id):
    user = db.get_or_404(User, user_id)
    if user != current_user:
        return abort(403, "You do not have permission to perform this action.")
    if request.method == "POST":
        db.session.delete(user)
        db.session.commit()
        flash("Your account has been deleted", "warning")
        return redirect(url_for("blogs.home_page"))
    return render_template("delete_user.html", user=user)

Templates

Also in the templates directory, we are going to create three new files for the get_user() , update_user_account() and delete_user_account() view.

templates/get_user.html This template shows a user's page and lists all the blogs posted by the user.

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_messages, render_icon %}


{% block body %}
<p>{{ render_messages(dismissible=True, dismiss_animate=True) }}</p>
{% if current_user == user %}
    <h3>My Account <a id="chng_pwd" href="{{ url_for('auth.change_password') }}">Change Password</a></h3>
{% else %}
    <h3 >{{ user.get_full_name()|title }}</h3>
{% endif %}

<div class="about">
    <p>{{ user.about.capitalize() }}</p>
    <p>Contact <br>{{ render_icon('envelope') }}: <a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
</div>

{% if current_user == user %}
    <div class  ="account_options" >
        <a id="update" class="btn btn-outline-secondary p-2 me-5" href="{{ url_for('users.update_user_account', user_id=current_user.id) }}">Update Account</a>
        <a id="delete" class="btn btn-outline-danger p-2" href="{{ url_for('users.delete_user_account', user_id=current_user.id) }}">Delete Account</a>    
    </div>
{% endif %}

{% if user.blogs %}
    <hr>
    <div class="my-5">
        <h4>Blogs by {{ user }}</h4>
        <div class="blog_container">
            {% for blog in user.blogs %}
            <a id="u_blogs" href="{{ url_for('blogs.get_single_blog', blog_id=blog.id) }}">{{ blog.title|title }}</a>
            &ThickSpace; - {{ blog.date_posted.date().strftime('%Y/%m/%d') }} <br>
            {% endfor %}
        </div>
    </div>
{% endif %}
{% endblock %}

templates/update_user.html simply renders the user form for a user to update/edit their details.

{% extends 'base.html' %}
{% from 'bootstrap5/form.html' import render_form %}
{% from 'bootstrap5/utils.html' import render_icon %}


{% block body %}
<h3>Update Account</h3>
{{ render_form(form, extra_classes='user_form') }}  
{% endblock %}

templates/delete_user.html is for the delete_user() view and renders a page where users can delete their accounts. The user would get a warning that all the blogs and comments they've posted would also be deleted.

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon %}


{% block body %}
<h3>Delete Account</h3>
{% if user.blogs %}
    <h5>Deleting your account will also delete all your blogs</h5>
    <div class="blog_container my-5">
        {% for blog in user.blogs %}
            <a id="u_blogs" href="{{ url_for('blogs.get_single_blog', blog_id=blog.id) }}">{{ blog.title|title }}</a>
            &ThickSpace; - {{ blog.date_posted.date().strftime('%Y/%m/%d') }} <br>
        {% endfor %}
    </div>
{% else %}
    <p class="my-5"><h5>This action cannot be undone</h5></p>
{% endif %}
<form action="" class="form" method="post">
    <div>
        <input class="btn btn-danger me-5" type="submit" value="Delete">
        <a id="cancel" class="btn btn-outline-secondary" href="{{ url_for('users.get_user', user_id=current_user.id) }}">Cancel</a>
    </div>
</form>
{% endblock %}

Authentication

The final stage is authentication which involves user registration, logging in and logging out users and also changing passwords. We only need to implement the registration form which is similar to the user form we did earlier, the login form and change password form then write view functions for each of them most of which we have already done before.

Create two new files forms.py and views.py in the auth directory.

Forms

auth/forms.py

from flask_wtf import FlaskForm
from wtforms import EmailField, PasswordField, StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError

from database.db import db
from users.models import User


class RegisterForm(FlaskForm):
    first_name = StringField("First Name", validators=[DataRequired()])
    last_name = StringField("Last Name", validators=[DataRequired()])
    email = EmailField("Email Address", validators=[DataRequired(), Email()])
    about = TextAreaField("About Yourself", validators=[DataRequired()])
    password = PasswordField(
        "Password",
        validators=[
            DataRequired(),
            EqualTo("password2", "Passwords don't match"),
            Length(min=8),
        ],
    )
    password2 = PasswordField("Confirm Password", validators=[DataRequired()])
    register = SubmitField("Register")

    def validate_email(self, field):
        """Check that the email is unique before processing the form data"""
        email_exists = db.session.query(User).filter(User.email == field.data).first()
        if email_exists:
            raise ValidationError("User with this email address already exists")

We defined a validate_email() method that checks if the email provided in the registration form is unique before processing data submitted by the user.

class LoginForm(FlaskForm):
    email = EmailField("Email Address", validators=[DataRequired(), Email()])
    password = PasswordField("Password", validators=[DataRequired()])
    log_in = SubmitField("Log In")

    def validate_email(self, field):
        """Check that the email exists before processing the form data"""
        user = db.session.query(User).filter(User.email == field.data).first()
        if not user:
            raise ValidationError(f"No user with email address: {field.data}")

In the LoginForm , we also define a validate_email() method like we did in the RegisterForm but in this case, the method checks if the email submitted in the form exists before any other form processing is done.

class ChangePasswordForm(FlaskForm):
    old_password = PasswordField("Old Password", validators=[DataRequired()])
    new_password = PasswordField(
        "New Password",
        validators=[
            DataRequired(),
            EqualTo("new_password2", "Passwords don't match"),
            Length(min=8),
        ],
    )
    new_password2 = PasswordField("Confirm New Password", validators=[DataRequired()])
    change = SubmitField("Change Password")

    def validate_new_password(self, field):
        if field.data == self.old_password.data:
            raise ValidationError("New password cannot be the same as old password")

Here, the validate_new_password() method checks that the new password provided by the user is not the same as the old password. If not a validation error is sent back to the user.

Views

auth/views.py

from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from werkzeug.security import generate_password_hash

from database.db import db
from users.models import User

from .forms import LoginForm, ChangePasswordForm, RegisterForm

auth_bp = Blueprint("auth", __name__, url_prefix="/auth/")
import app_conf
from flask import Flask

from blogs.views import blogs_bp
from users.views import users_bp
from database.db import db

app = Flask(__name__)
app.config.from_object(app_conf)
app.register_blueprint(auth_bp)
app.register_blueprint(blogs_bp) 
app.register_blueprint(users_bp) 
db.init_app(app)

@app.before_first_request
def create_database():
    db.create_all()

Starting with the register() view, instantiate the RegisterForm and the User model, on submission of the form, populate the user object with data submitted in the RegisterForm and commit to the database.

@auth_bp.route("/register/", methods=["GET", "POST"])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        password = form.password.data
        form.password.data = generate_password_hash(password)
        user = User()
        form.populate_obj(user)
        db.session.add(user)
        db.session.commit()
        login_user(user)
        flash("Registration was successful", "success")
        return redirect(url_for("blogs.home_page"))
    return render_template("register.html", form=form)

The generate_password_hash() function takes the password string from the form as an argument and returns the hashed version of the string. This hash is saved as the user's password (and not the string supplied in the form) in the database after which the user is logged in and gets a confirmatory message that the registration was successful.

This is the code for the log_in_user() and log_out_user() view.

@auth_bp.route("/log_in/", methods=["GET", "POST"])
def log_in_user():
    logout_user()
    form = LoginForm()
    if form.validate_on_submit():
        email = form.email.data
        password = form.password.data
        user = db.session.query(User).filter(User.email == email).first()
        if user and check_password_hash(user.password, password):
            login_user(user)
            next_url = request.args.get("next")
            if next_url:
                return redirect(next_url)
            flash("You are logged in successfully", "success")
            return redirect(url_for("blogs.home_page"))
        else:
            flash("Invalid email or password", "error")
    return render_template("log_in.html", form=form)


@auth_bp.route("/log_out/", methods=["GET", "POST"])
def log_out_user():
    if request.method == "POST":
        logout_user()
        flash("You are now logged out", "warning")
        return redirect(url_for("blogs.home_page"))
    return render_template("log_out.html")

We instantiate the LoginForm to be able to get the data passed in each field in the form. Using the email provided in the form, we get the user object from the database then use the check_pasword_hash() function and check if the password submitted in the form is the same as the password of the user object. And for the log_out_user() view, when a user clicks on the logout button, the user is logged out of their account and redirected to the home page.

In the change_password() view, we instantiate the ChangePasswordForm to extract the old password and new password from the form. Run a query that returns a user object from the browser's session and check if the user object's password is the same as the old password (from the from). If so, we generate a new password hash using the generate_password_hash() function and this new password hash is then saved as the user's password else we return an error message that the password is incorrect.

@auth_bp.route("/change_password/", methods=["GET", "POST"])
@login_required
def change_password():
    form = ChangePasswordForm()
    if form.validate_on_submit():
        old_pwd = form.old_password.data
        new_pwd = form.new_password.data
        user = db.session.query(User).filter(User.id == current_user.id).first()
        if user and check_password_hash(user.password, old_pwd):
            user.password = generate_password_hash(new_pwd)
            db.session.commit()
            flash("Password changed succesfully", "success")
            return redirect(url_for("users.get_user", user_id=current_user.id))
        else:
            flash("Password is not correct", "error")
    return render_template("change_password.html", form=form)

Templates

For their templates, we extend the base template as usual and render their respective forms although the log_out_user() view will not have a template as it's just a clickable button and doesn't involve the submission of any form.

templates/register.html

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon, render_messages %}
{% from 'bootstrap5/form.html' import render_form %}

{% block body %}
<h3>Register</h3>
{{ render_messages(dismissible=True, dismiss_animate=True) }}
{{ render_form(form, extra_classes='auth_form') }}
{% endblock %}

templates/log_in.html

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon, render_messages %}
{% from 'bootstrap5/form.html' import render_form %}

{% block body %}
<h3>Log In</h3>
{{ render_messages(dismissible=True, dismiss_animate=True) }}
{{ render_form(form, extra_classes='auth_form') }}  
{% endblock %}

templates/change_password.html

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon, render_messages %}
{% from 'bootstrap5/form.html' import render_form %}

{% block body %}
<h3>Change Password</h3>
{{ render_messages(dismissible=True, dismiss_animate=True) }}
{{ render_form(form, extra_classes='auth_form') }}  
{% endblock %}

Additional Settings

At the moment, we are done setting up our application but there are settings we need to implement for user session management provided by flask-login package and there's a need to import our models(blog, comment and user models) into the main.py file for the application to locate it.

Open your main.py file and make the following changes.

import app_conf
from flask import Flask
from flask_login import LoginManager

from blogs.models import Blog, Comment
from blogs.views import blogs_bp
from users.models import User
from users.views import users_bp
from database.db import db

app = Flask(__name__)
app.config.from_object(app_conf)
app.register_blueprint(auth_bp)
app.register_blueprint(blogs_bp) 
app.register_blueprint(users_bp) 
db.init_app(app)

@app.before_first_request
def create_database():
    db.create_all()


login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "auth.log_in_user"
login_manager.login_message_category = "warning"

@login_manager.user_loader
def load_user(id):
    return User.query.get(int(id))

The LoginManager object handles the common tasks of logging in, logging out, and remembering your users’ sessions over extended periods and among other things you can read from the documentation.

We can also go ahead and add an about page and a contact page for our application to give users an idea of what our application is about and know how to reach us. Add this to the main.py file.

@app.route("/about/", methods=["GET"])
def about_page():
    return render_template("about_page.html")


@app.route("/contact-us/", methods=["GET", "POST"])
def contact_page():
    form = ContactForm()
    if form.validate_on_submit():
        name = form.name.data
        email = form.email.data
        message = form.message.data
        flash(
            "Your message was sent successfully. We'd get back to you as soon as possible",
            "success",
        )
        return redirect("/contact-us/")
    return render_template("contact_page.html", form=form)

For the contact page, we added a ContactForm class which we would define later in users/forms.py file. Users that want to contact us can fill out and submit the form but we would not implement an actual mail-sending functionality for it.

Add this code to users/form.py file for the ContactForm .

class ContactForm(FlaskForm):
    name = StringField("Name", validators=[DataRequired()])
    email = EmailField("Email Address", validators=[DataRequired(), Email()])
    message = TextAreaField("Message", validators=[DataRequired()])
    submit = SubmitField("Submit")

Here goes the code for their templates.

about_page.html contains a brief note on our application's mission and what we do.

{% extends 'base.html' %}
{% from 'bootstrap5/utils.html' import render_icon %}


{% block body %}
<div class="my-5 about_page">
    <div class="mb-5">
        <h2 class="border-0 mb-4 text-info">Welcome to Bloggr {{ render_icon('emoji-smile') }}</h2>
        <h5> A weblog application service for everyone and everything. </h5>
    </div>

    <div>
        <h3 class="border=0 text-info">Mission</h3>
        Our mission is to create a safe-space for users around the world to express themselves
        or write about whatever they want to without fear of any kind. <br> <br>

        Knowing how wild and vast the human mind can be, we want to create a safe, non-judgemental community where people can
        openly and willingly share their thoughts, regardless of who, where and when it came from or what it's about.
        <p>We are open to every category of contents. Sports, documentaries, politics, festivals etc...</p>
    </div>

    <p>Come and share with us your wildest thoughts and ideas. </p>
    <a class="btn btn-primary my-5" id="register" href="{{ url_for('auth.register') }}">Get Started</a>

</div>
<hr>
<footer>Copyright (c) 2022 <br> All Rights Reserved</footer>
{% endblock %}

contact_page.html simply renders the ContactForm and the contact details like email, address and phone we provided.

{% extends 'base.html' %}
{% from 'bootstrap5/form.html' import render_form %}
{% from 'bootstrap5/utils.html' import render_icon, render_messages %}


{% block body %}
<p>{{ render_messages(dismissible=True, dismiss_animate=True) }}</p>
<div class="my-5 extra">
    <h2 class="me-5 mb-5 text-info">Get in touch</h2>
    <div>
        Looking for a way to reach us or need more information about our services?<br> <br>
        We would be happy to answer your questions and give you the information you seek. <br> <br>
        <div>
            <p> You can contact us at</p><br>
            <p>{{ render_icon('envelope') }}: <a href="mailto:akpulukelvin@gmail.com">akpulukelvin@gmail.com</a></p>
            <p>{{ render_icon('telephone') }}: (+234)-8175380665</p>
            <p>{{ render_icon('geo-alt') }}: 195 Ifite Awka, Anambra, NGA</p>
        </div>
    </div>
    <hr>
    <h5 class="border-0">Or you can send us a message directly</h5>
    <div>{{ render_form(form, extra_classes='contact_form') }}</div>
</div>
<hr>
<footer>Copyright (c) 2022 <br> All Rights Reserved</footer>
{% endblock %}

and we are finally done!!!

With the application still running, open your browser and go to http://127.0.0.1:5000 and you should see this.

As you can see, our application is live! Go ahead and create a new account and post blogs!!

Source Code

You can view the source code for the application here. Feel free to fork it and make changes as you wish.

Thank You For Reading!