Skip to main content

Building a Web Application with Python and Flask

Hands-On Lab

 

Photo of Keith Thompson

Keith Thompson

DevOps Training Architect II in Content

Length

04:00:00

Difficulty

Beginner

Python is a great language for web development and the community has built fantastic tools to make the process enjoyable. In this hands-on lab, we're going to build a web application using the Python web framework, Flask. Our application will present a JSON API and also render views with information coming from a PostgreSQL database. By the time we've finished, we'll have seen some of the power that Python provides when being used for web development.

What are Hands-On Labs?

Hands-On Labs are scenario-based learning environments where learners can practice without consequences. Don't compromise a system or waste money on expensive downloads. Practice real-world skills without the real-world risk, no assembly required.

Building a Web Application with Python and Flask

Introduction

Python is a great language for web development and the community has built fantastic tools to make the process enjoyable. In this hands-on lab, we're going to build a web application using the Python web framework, Flask. Our application will present a JSON API and also render views with information coming from a PostgreSQL database. By the time we've finished, we'll have seen some of the power that Python provides when being used for web development.

Solution

  1. Begin by logging in to the Workstation lab server using the credentials provided on the hands-on lab page:

    ssh cloud_user@PUBLIC_IP_ADDRESS

Create project and virtualenv

  1. To get started, we're going to create a directory to hold onto our project. We'll call this tickets:

    mkdir tickets
    cd tickets
  2. With this set up, we need to create our Virtualenv and install some dependencies:

    pipenv --python=$(which python3.7) install flask
  3. For the rest of our work, we'll want to make sure that we're using our active Virtualenv. Let's activate it now:

    pipenv shell

Configure application and connect to the database

We're ready to create the initial layout of the application and set up our database configuration. To begin, we'll create an __init__.py script to generate our application. We'll take an approach very similar to the official Flask tutorial, setting up an application factory, starting with a file named __init__.py.

  1. Create the file __init__.py:

    vim ~/tickets/__init__.py
  2. Provide the following contents:

    import os
    
    from flask import Flask
    
    def create_app(test_config=None):
        app = Flask(__name__)
        app.config.from_mapping(
            SECRET_KEY=os.environ.get('SECRET_KEY', default='dev'),
        )
    
        if test_config is None:
            app.config.from_pyfile('config.py', silent=True)
        else:
            app.config.from_mapping(test_config)
    
        return app

Now we can test the bare-bones application. We're going to change the port to 3000 because Linux Academy servers have that open by default:

Note: You'll want to do this in a separate terminal instance so that we can keep it running. It will auto reload the code as we make changes.

  1. Open a new tab in your terminal application and log in to the Workstation lab server:

    ssh cloud_user@PUBLIC_IP_ADDRESS

    Change directories to tickets:

    cd tickets/

    Use our Virtualenv:

    pipenv shell
  2. Test the bare-bones application with the following commands:

    export FLASK_ENV=development
    export FLASK_APP='.'
    flask run --host=0.0.0.0 --port=3000

    Output from this command should look like this:

     * Serving Flask app "." (lazy loading)
     * Environment: development
     * Debug mode: on
     * Running on http://0.0.0.0:3000/ (Press CTRL+C to quit)
     * Restarting with stat
     * Debugger is active!
     * Debugger PIN: 112-739-965

    > Note: Leave this command running and switch back to the first terminal window to continue.

Our next step will be to install a library for interacting with PostgreSQL and configuring our database connection. For this, we'll use psycopg2 and the Flask-SQLAlchemy plugin.

  1. Let's install these now:

    pipenv install psycopg2 Flask-SQLAlchemy
  2. Output from this command will look like this:

    Installing psycopg2…
    Adding psycopg2 to Pipfile's [packages]…
    ✔ Installation Succeeded
    Installing Flask-SQLAlchemy…
    Adding Flask-SQLAlchemy to Pipfile's [packages]…
    ✔ Installation Succeeded
    Pipfile.lock (caf66b) out of date, updating to (662286)…
    Locking [dev-packages] dependencies…
    Locking [packages] dependencies…
    ✔ Success!
    Updated Pipfile.lock (caf66b)!
    Installing dependencies from Pipfile.lock (caf66b)…
       ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 9/9 — 00:00:02

We'll set up our database configuration with a new file called config.py:

  1. Create the file config.py:

    vim ~/tickets/config.py
  2. Provide the following contents:

    > Note: Be sure to replace < DB_PRIVATE_IP > with the private IP address for the Database server provided on the hands-on lab page.

    import os
    
    db_host = os.environ.get('DB_HOST', default='< DB_PRIVATE_IP >')
    db_name = os.environ.get('DB_NAME', default='dashboard')
    db_password = os.environ.get('DB_PASSWORD', default='secure_password')
    db_port = os.environ.get('DB_PORT', default='5432')
    db_user = os.environ.get('DB_USERNAME', default='dashboard')
    
    SQLALCHEMY_DATABASE_URI = f"postgres://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"

We're using environment variables and os.environ.get so that we can easily adjust these with environment variables. Next, let's create a file (models.py) to contain our database logic and classes wrapping database tables. We'll need to pull in the flask_sqlalchemy package to initialize a database object that won't actually connect to the application's database just yet:

  1. Create the file models.py:

    vim ~/tickets/models.py
  2. Provide the following contents:

    from flask_sqlalchemy import SQLAlchemy
    
    db = SQLAlchemy()
    
    class Ticket(db.Model):
        id = db.Column(db.Integer, primary_key=True)
        name = db.Column(db.String(100), nullable=False)
        status = db.Column(db.Integer, nullable=False)
        url = db.Column(db.String(100), nullable=True)
    
        statuses_dict = {
            0: 'Reported',
            1: 'In Progress',
            2: 'In Review',
            3: 'Resolved',
        }
    
        def status_string(self):
            return self.statuses_dict[self.status]

Now we have a Ticket class that we can use to work with the information from our database and a db object that we can initialize with our application configuration. That initialization needs to happen within our create_app function in the __init__.py:

  1. Edit the __init__.py file:

    vim ~/tickets/__init__.py
  2. Make the necessary changes. Your file should look like this:

    import os
    
    from flask import Flask
    
    def create_app(test_config=None):
        app = Flask(__name__)
        app.config.from_mapping(
            SECRET_KEY=(os.getenv('SECRET_KEY') or 'dev'),
        )
    
        if test_config is None:
            # Load configuration from config.py
            app.config.from_pyfile('config.py', silent=True)
        else:
            app.config.from_mapping(test_config)
    
        from .models import db
        db.init_app(app)
    
        return app

Now we have a working application configuration that can communicate with our database.

Render /tickets and /tickets/:id views

For the HTML based views, we're going to use some HTML templates that a co-worker created for us that have some comments on where to put the dynamic information. These files can be found within ~/templates. We also need to move over the styles from ~/static.

  1. Let's copy those directories into our application now:

    mv ~/templates .
    mv ~/static .

Before worrying about the templates themselves, let's create the request handler functions. These handlers will be defined as functions within our create_app function in the __init__.py file:

  1. Edit the __init__.py file:

    vim ~/tickets/__init__.py
  2. Make the necessary changes. Your file should look like this:

    import os
    
    from flask import Flask, redirect, render_template, url_for
    
    def create_app(test_config=None):
        app = Flask(__name__)
        app.config.from_mapping(
            SECRET_KEY=(os.getenv('SECRET_KEY') or 'dev'),
        )
    
        if test_config is None:
            # Load configuration from config.py
            app.config.from_pyfile('config.py', silent=True)
        else:
            app.config.from_mapping(test_config)
    
        from .models import db
        db.init_app(app)
    
        @app.route('/')
        def index():
            return redirect(url_for('tickets'))
    
        @app.route('/tickets')
        def tickets():
            return render_template('tickets_index.html')
    
        @app.route('/tickets/<int:ticket_id>')
        def tickets_show(ticket_id):
            return render_template('tickets_show.html')
    
        return app

For now, we just want to make sure that our templates are being rendered out properly. If we reload the browser, we should see them and if we visit the root URL we will be redirected to the /tickets URL. Our next step is to query our database for the tickets so that we can pass them to our templates. In the tickets function, we'll fetch all of the items from the database to list out. From the tickets_show function, we'll fetch just one based on the ID that is passed into the URL. Let's add this logic now:

  1. Edit the __init__.py file:

    vim ~/tickets/__init__.py
  2. Make the necessary changes. Your file should look like this:

    import os
    
    from flask import Flask, abort, redirect, render_template, url_for
    
    def create_app(test_config=None):
        app = Flask(__name__)
        app.config.from_mapping(
            SECRET_KEY=(os.getenv('SECRET_KEY') or 'dev'),
        )
    
        if test_config is None:
            # Load configuration from config.py
            app.config.from_pyfile('config.py', silent=True)
        else:
            app.config.from_mapping(test_config)
    
        from .models import db, Ticket
        db.init_app(app)
    
        from sqlalchemy.orm import exc
    
        @app.errorhandler(404)
        def page_not_found(e):
            return render_template('404.html'), 404
    
        @app.route('/')
        def index():
            return redirect(url_for('tickets'))
    
        @app.route('/tickets')
        def tickets():
            tickets = Ticket.query.all()
            return render_template('tickets_index.html', tickets=tickets)
    
        @app.route('/tickets/<int:ticket_id>')
        def tickets_show(ticket_id):
            try:
                ticket = Ticket.query.filter_by(id=ticket_id).one()
                return render_template('tickets_show.html', ticket=ticket)
            except exc.NoResultFound:
                abort(404)
    
        return app

Notice that we had to add a little error handling to the tickets_show view function because it's possible for someone to pass in an ID that doesn't exist. In this case, we're going to use the one function that will throw a sqlalchemy.orm.exc.NoResultFound error if there is not a matching row. We catch that error in our except and then Flask will automatically run the function that we've decorated with @app.errorhandler(404).

The last thing that we need to do is utilize the tickets and ticket variables within templates/tickets_index.html and templates/tickets_show.html respectively. Here are the lines that we adjusted in each:

  1. Edit the tickets_index.html file:

    vim ~/tickets/templates/tickets_index.html
  2. The adjusted lines should look like this:

      <!-- Contents above this comment were omitted -->
      <!-- EXAMPLE ROW, substitue the real information from the tickets in the database -->
      {% for ticket in tickets %}
        <tr>
          <th>{{ticket.id}}</th>
          <td>{{ticket.name}}</td>
          <td>{{ticket.status_string()}}</td>
          <td>
            <a href="{{ticket.url}}">{{ticket.url}}</a>
          </td>
          <td>
            <a href="{{url_for('tickets_show', ticket_id=ticket.id)}}">Details</a>
          </td>
        </tr>
      {% endfor %}
      <!-- Contents below this comment were omitted -->
  3. Edit the tickets_show.html file:

    vim ~/tickets/templates/tickets_show.html
  4. The adjusted lines should look like this:

    {% extends "layout.html" %}
    {% block title %}Ticket - {{ticket.name}}{% endblock %}
    
    {% block body %}
    <div class="content">
      <p><strong>Name:</strong> {{ticket.name}}</p>
      <p><strong>Status:</strong> {{ticket.status_string()}}</p>
      <p><strong>URL:</strong> <a href="{{ticket.url}}" target="_blank">{{ticket.url}}</a></p>
    </div>
    {% endblock %}

> Note: It's important that we call the status_string method using parenthesis, otherwise we'll render a message about there being a method bound to the ticket variable on the page.

Add /api/tickets and /api/tickets/:id JSON API endpoints

We had to do quite a bit to render our HTML views, but that lays the groundwork for us to pretty easily create our API view functions. Let's utilize the jsonify helper that Flask provides to create our final two view functions with __init__.py.

  1. Edit the __init__.py file:

    vim ~/tickets/__init__.py
  2. Make the necessary changes. The file should now look like this:

    import os
    
    from flask import Flask, abort, redirect, render_template, url_for, jsonify
    
    def create_app(test_config=None):
        app = Flask(__name__)
        app.config.from_mapping(
            SECRET_KEY=(os.getenv('SECRET_KEY') or 'dev'),
        )
    
        if test_config is None:
            # Load configuration from config.py
            app.config.from_pyfile('config.py', silent=True)
        else:
            app.config.from_mapping(test_config)
    
        from .models import db, Ticket
        db.init_app(app)
    
        from sqlalchemy.orm import exc
    
        @app.errorhandler(404)
        def page_not_found(e):
            return render_template('404.html'), 404
    
        @app.route('/')
        def index():
            return redirect(url_for('tickets'))
    
        @app.route('/tickets')
        def tickets():
            tickets = Ticket.query.all()
            return render_template('tickets_index.html', tickets=tickets)
    
        @app.route('/tickets/<int:ticket_id>')
        def tickets_show(ticket_id):
            try:
                ticket = Ticket.query.filter_by(id=ticket_id).one()
                return render_template('tickets_show.html', ticket=ticket)
            except exc.NoResultFound:
                abort(404)
    
        @app.route('/api/tickets')
        def api_tickets():
            tickets = Ticket.query.all()
            return jsonify(tickets)
    
        @app.route('/api/tickets/<int:ticket_id>')
        def api_tickets_show(ticket_id):
            try:
                ticket = Ticket.query.filter_by(id=ticket_id).one()
                return jsonify(ticket)
            except exc.NoResultFound:
                return jsonify({'error': 'Ticket not found'}), 404
    
        return app

This looks great, but unfortunately, we receive an error when we try to access the /api/tickets URL in the browser because Object of type Ticket is not JSON serializable. This is something that we'll need to fix within our model by adding a to_json method that we can call before we pass our object to jsonify:

  1. Edit the models.py file:

    vim ~/tickets/models.py
  2. Make the necessary changes. The file should now look like this:

    from flask_sqlalchemy import SQLAlchemy
    
    db = SQLAlchemy()
    
    class Ticket(db.Model):
        id = db.Column(db.Integer, primary_key=True)
        name = db.Column(db.String(100), nullable=False)
        status = db.Column(db.Integer, nullable=False)
        url = db.Column(db.String(100), nullable=True)
    
        statuses_dict = {
            0: 'Reported',
            1: 'In Progress',
            2: 'In Review',
            3: 'Resolved',
        }
    
        def status_string(self):
            return self.statuses_dict[self.status]
    
        def to_json(self):
            """
            Return the JSON serializable format
            """
            return {
                'id': self.id,
                'name': self.name,
                'status': self.status_string(),
                'url': self.url
            }

Now we can utilize this function in our view functions:

  1. Edit the __init__.py file:

    vim ~/tickets/__init__.py
  2. Make the necessary changes. The changes will be:

        # Extra code omitted
    
        @app.route('/api/tickets')
        def api_tickets():
            tickets = Ticket.query.all()
            return jsonify([ticket.to_json() for ticket in tickets])
    
        @app.route('/api/tickets/<int:ticket_id>')
        def api_tickets_show(ticket_id):
            try:
                ticket = Ticket.query.filter_by(id=ticket_id).one()
                return jsonify(ticket.to_json())
            except exc.NoResultFound:
                return jsonify({'error': 'Ticket not found'}), 404
    
        # Extra code omitted

Conclusion

Congratulations — you've completed this hands-on lab!