Skip to content

33. Application exercise: Version 13

Version 13 modifies version 12 in the following ways:

  • Certain parts of the code have been restructured (refactoring);
  • session management is handled differently using the [flask_session] module;
  • encrypted passwords are used in the configuration file;

Version 13 is initially created by copying version 12:

Image

Image

  • in [1], the configuration will be refactored. In particular, it will be moved out of the [flask] folder;
  • in [2], the main script will be refactored. It is also moved out of the [flask] folder;
  • in [3], the configuration will be split across multiple files;
  • in [4]: we will simplify the main script [main] by moving code to other files;
  • in [5], the authentication controller will be modified since user passwords will now be encrypted;
  • in [6], the main controller will incorporate code previously present in the main script [main];

33.1. Refactoring the application configuration

Image

Three new configuration files are created:

  • [mvc]: to configure the application’s MVC architecture;
  • [parameters]: which will contain all the application’s constants;
  • [syspath]: which configures the application’s Python path;

The [syspath.py] file is as follows:


def configure(config: dict) -> dict:
    import os

    # directory of this file
    script_dir = os.path.dirname(os.path.abspath(__file__))

    # root path
    root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"

    # dependencies
    absolute_dependencies = [
        # project directories
        # BaseEntity, MyException
        f"{root_dir}/classes/02/entities",
        # TaxDaoInterface, TaxBusinessInterface, TaxUiInterface
        f"{root_dir}/taxes/v04/interfaces",
        # AbstractTaxDao, TaxConsole, TaxBusiness
        f"{root_dir}/taxes/v04/services",
        # TaxDaoWithAdminDataInDatabase
        f"{root_dir}/taxes/v05/services",
        # AdminData, TaxError, TaxPayer
        f"{root_dir}/impots/v04/entities",
        # Constants, tax brackets
        f"{root_dir}/taxes/v05/entities",
        # Logger, SendAdminMail
        f"{root_dir}/taxes/http-servers/02/utilities",
        # directory of the main script
        script_dir,
        # configs [database, layers, parameters, controllers, views]
        f"{script_dir}/../configs",
        # controllers
        f"{script_dir}/../controllers",
        # HTTP responses
        f"{script_dir}/../responses",
        # view models
        f"{script_dir}/../models_for_views",
    ]

    # Set the syspath
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # return the configuration
    return {
        "root_dir": root_dir,
        "script_dir": script_dir
    }
  • The [syspath] script is used to configure the application's Python Path (lines 40–41);
  • it provides two pieces of information useful to the other configuration scripts (lines 45–46);

The [mvc] script configures the MVC architecture of the JSON/XML/HTML web application:


def configure(config: dict) -> dict:
    # MVC application configuration

    # controllers
    from DisplayTaxCalculationController import DisplayTaxCalculationController
    from AuthenticateUserController import AuthenticateUserController
    from CalculateTaxesController import CalculateTaxesController
    from CalculateTaxesController import CalculateTaxesController
    from EndSessionController import EndSessionController
    from GetAdminDataController import GetAdminDataController
    from InitSessionController import InitSessionController
    from ListSimulationsController import ListSimulationsController
    from MainController import MainController
    from DeleteSimulationController import DeleteSimulationController
    # HTTP responses
    from HtmlResponse import HtmlResponse
    from JsonResponse import JsonResponse
    from XmlResponse import XmlResponse
    # view models
    from ModelForAuthentificationView import ModelForAuthentificationView
    from ModelForTaxCalculationView import ModelForTaxCalculationView
    from ModelForErrorView import ModelForErrorView
    from ModelForSimulationListView import ModelForSimulationListView

    # Authorized actions and their controllers
    controllers = {
        # Initialization of a calculation session
        "init-session": InitSessionController(),
        # user authentication
        "authenticate-user": AuthenticateUserController(),
        # calculate tax in individual mode
        "calculate-tax": CalculateTaxController(),
        # calculate tax in batch mode
        "calculate-taxes": CalculateTaxesController(),
        # list of simulations
        "list-simulations": ListSimulationsController(),
        # Delete a simulation
        "delete-simulation": DeleteSimulationController(),
        # end of calculation session
        "end-session": EndSessionController(),
        # display the tax calculation view
        "display-tax-calculation": DisplayTaxCalculationController(),
        # retrieve data from the tax administration
        "get-admindata": GetAdminDataController(),
        # main controller
        "main-controller": MainController()
    }
    # different response types (json, xml, html)
    responses = {
        "json": JsonResponse(),
        "html": HtmlResponse(),
        "xml": XmlResponse()
    }
    # HTML views and their models depend on the state returned by the controller
    views = [
        {
            # authentication view
            "statuses": [
                700,    # /init-session - success
                201,    # /authenticate-user failure
            ],
            "view_name": "views/authentication-view.html",
            "model_for_view": ModelForAuthentificationView()
        },
        {
            # tax calculation view
            "statements": [
                200,    # /authenticate-user success
                300,    # /calculate-tax success
                301,    # /calculate-tax failure
                800,    # /display-tax-calculation success
            ],
            "view_name": "views/tax-calculation-view.html",
            "model_for_view": ModelForCalculImpotView()
        },
        {
            # view of the list of simulations
            "statements": [
                500,    # /list-simulations success
                600,    # /delete-simulation success
            ],
            "view_name": "views/simulation-list-view.html",
            "model_for_view": ModelForListeSimulationsView()
        },

    ]
    # unexpected errors view
    view_errors = {
        "view_name": "views/error-view.html",
        "model_for_view": ModelForErrorsView()
    }
    # redirects
    redirects = [
        {
            "statuses": [
                400,  # /end-session success
            ],
            # redirect to the URL
            "to": "/init-session/html",
        }
    ]


    # return the MVC configuration
    return {
        # controllers
        "controllers": controllers,
        # HTTP responses
        "responses": responses,
        # views and models
        "views": views,
        # list of redirects
        "redirects": redirects,
        # unexpected error view
        "error_view": error_view
    }
  • lines 1-101: this code is known;
  • Lines 105–116: We render the application’s MVC configuration;

The [parameters] script collects the application's constants:


def configure(config: dict) -> dict:
    # application configuration

    # script_dir
    script_dir = config['syspath']['script_dir']

    # application configuration
    parameters = {
        # users authorized to use the application
        "users": [
            {
                "login": "admin",
                "password": "$pbkdf2-sha256$29000$mPM.h3COkTIGYOzde68VIg$7LH5Q7rN/1hW.Xa.6rcmR6h52PntvVqd5.na7EtgQNw"
            }
        ],
        # log file
        "logsFilename": f"{script_dir}/../data/logs/logs.txt",
        # SMTP server configuration
        "adminMail": {
            # SMTP server
            "smtp-server": "localhost",
            # SMTP server port
            "smtp-port": "25",
            # administrator
            "from": "guest@localhost.com",
            "to": "guest@localhost.com",
            # email subject
            "subject": "Tax calculation server crash",
            # Set TLS to True if the SMTP server requires authentication, False otherwise
            "tls": False
        },
        # thread pause duration in seconds
        "sleep_time": 0,
        # Redis server
        "redis": {
            "host": "127.0.0.1",
            "port": 6379
        },
    }

    # Return the application configuration
    return parameters
  • line 13: user passwords will now be encrypted;
  • lines 34–38: configuration of a [Redis] server, which we will return to later;

With these new configuration files, the [config] script becomes the following:


def configure(config: dict) -> dict:
    # syspath configuration
    import syspath
    config['syspath'] = syspath.configure(config)

    # Application configuration
    import parameters
    config['parameters'] = parameters.configure(config)

    # database configuration
    import database
    config["database"] = database.configure(config)

    # instantiate the application layers
    import layers
    config['layers'] = layers.configure(config)

    # MVC configuration of the [web] layer
    import mvc
    config['mvc'] = mvc.configure(config)

    # return the configuration
    return config

33.2. Refactoring the main script [main]

Image

The main script [main.py] simply starts the server:


# Expects a mysql or pgres parameter
import sys

syntax = f"{sys.argv[0]} mysql / pgres"
error = len(sys.argv) != 2
if not error:
    dbms = sys.argv[1].lower()
    error = dbsystem != "mysql" and dbsystem != "pgres"
if error:
    print(f"syntax: {syntax}")
    sys.exit()

# configure the application
import config
config = config.configure({'db': db})

# dependencies
from SendAdminMail import SendAdminMail
from Logger import Logger
from ImpôtsError import ImpôtsError
import redis

# Send an email to the administrator
def send_adminmail(config: dict, message: str):
    # Send an email to the application administrator
    config_mail = config['parameters']['adminMail']
    config_mail["logger"] = config['logger']
    SendAdminMail.send(config_mail, message)

# Check the log file
logger = None
error = False
error_message = None
try:
    # Logger
    logger = Logger(config['parameters']['logsFilename'])
except BaseException as exception:
    # console log
    print(f"The following error occurred: {exception}")
    # log the error
    error = True
    error_message = f"{exception}"
# store the logger in the config
config['logger'] = logger
# error handling
if error:
    # email to the administrator
    send_adminmail(config, error_message)
    # end of application
    sys.exit(1)

# startup log
log = "[server] server startup"
logger.write(f"{log}\n")

# Checking Redis server availability
redis_client = redis.Redis(host=config["parameters"]["redis"]["host"],
                           port=config["parameters"]["redis"]["port"])
# ping the Redis server
try:
    redis_client.ping()
except BaseException as exception:
    # Redis is not available
    log = f"[server] The Redis server is not available: {exception}"
    # console
    print(log)
    # log
    logger.write(f"{log}\n")
    # end
    sys.exit(1)

# Store the [redis] client in the config
config['redis_client'] = redis_client

# retrieve data from the tax authority
error = False
try:
    # admindata will be a read-only application-scope data
    config["admindata"] = config["layers"]["dao"].get_admindata()
    # success log
    logger.write("[server] database connection successful\n")
except ImpôtsError as ex:
    # log the error
    error = True
    # error log
    log = f"The following error occurred: {ex}"
    # console
    print(log)
    # log file
    logger.write(f"{log}\n")
    # email to the administrator
    send_adminmail(config, log)

# the main thread no longer needs the logger
logger.close()

# if there was an error, stop
if error:
    sys.exit(2)

# Import the web application routes
import routes
routes.config = config
routes.execute(__name__)
  • lines 56–73: a Redis server and its client are introduced;
  • lines 102–104: the application’s routes have been externalized into the [routes] script;

33.2.1. The [flask_session] and [redis] modules

The [Redis] server will be used to store user sessions. We will use the [flask_session] module to manage these sessions. This module can store user sessions in several locations. Redis is one of them, and we will use it.

The [flask_session] module must be installed in a PyCharm terminal:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\packages>pip install flask-session
Collecting flask-session

To communicate with the Redis server, we need a Redis client. This will be provided by the [redis] module, which we will also install:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\packages>pip install redis
Collecting redis

33.2.2. The Redis server

The Redis server will be used to store user sessions. The [flask_session] module works as follows:

  • Each user has a session ID, and that is what is sent to the client—and only that. The client receives a session cookie only once, after its first request. This cookie contains the user’s session ID, which will not change as the client makes subsequent requests. This is why the server does not need to send a new session cookie;
  • Previously, the session content was sent to the client. This will no longer be the case. The user’s session content will be stored on the Redis server;

Laragon comes with a Redis server that is not enabled by default. You must therefore start by enabling it:

Image

  • in [3], enable the [Redis] server;
  • in [4], leave port [6379], which Redis clients use by default;

Laragon services are automatically restarted after Redis is enabled:

Image

The Redis server can be queried in command mode. Open a Laragon terminal [6]:

Image

  • in [1], the [redis-cli] command launches the client in command mode for the Redis server;

As of July 2019, the Redis client can use 172 commands to interact with the server [https://redis.io/commands#list]. One of them [command count] [2] displays this number [3].

Writing to [Redis] is done using the Redis command [set attribute value] [4]. The value can then be retrieved using the command [get attribute] [5].

It may be necessary to clear the Redis database. This is done using the [flushdb] command [6]. Then, if you query the value of the [title] attribute [7], you get a [nil] reference [8] indicating that the attribute was not found. You can also use the [exists] command [9-10] to check if an attribute exists.

To exit the Redis client, type the [quit] command [11].

You can also use a web interface to manage the keys on the Redis server. To do this, the Laragon Apache server must be running:

Image

The following interface appears:

Image

  • in [1-4], one of the sessions stored on the Redis server;

33.2.3. Managing the Redis server in the main script [main]

The [main] script checks for the presence of the Redis server as follows:


import redis

# Check the availability of the Redis server
redis_client = redis.Redis(host=config["parameters"]["redis"]["host"],
                           port=config["parameters"]["redis"]["port"])
# ping the Redis server
try:
    redis_client.ping()
except BaseException as exception:
    # Redis is not available
    log = f"[server] The Redis server is unavailable: {exception}"
    # console
    print(log)
    # log
    logger.write(f"{log}\n")
    # end
    sys.exit(1)

# Store the [redis] client in the config
config['redis_client'] = redis_client
  • line 4: the class constructor [redis.Redis] creates a Redis server client. Its properties (address, port) are found in the [parameters] script;
  • line 8: the [ping] method checks for the presence of the Redis server;
  • lines 9–17: if the ping fails, the error is logged and the server is stopped;
  • line 20: the Redis client reference is stored in the configuration;

33.2.4. Route management in the main script [main]

Route handling in [main] is limited to the following lines:


# Import the web application routes
import routes
routes.config = config
routes.execute(__name__)
  • line 1: the routes have been externalized into the [routes] module;
  • line 3: the routes need to know the execution configuration;
  • line 4: we launch the Flask application by passing it the name of the executed script (__main__);

The routes script is as follows:


# dependencies
import os

from flask import Flask, redirect, request, session, url_for
from flask_api import status
from flask_session import Session

# Flask application
app = Flask(__name__, template_folder="../flask/templates", static_folder="../flask/static")

# application configuration
config = {}

# the front controller
def front_controller() -> tuple:
    # forward the request to the main controller
    main_controller = config['mvc']['controllers']['main-controller']
    return main_controller.execute(request, session, config)

@app.route('/', methods=['GET'])
def index() -> tuple:
    # Redirect to /init-session/html
    return redirect(url_for("init_session", type_response="html"), status.HTTP_302_FOUND)

# init-session
@app.route('/init-session/<string:type_response>', methods=['GET'])
def init_session(type_response: str) -> tuple:
    # execute the controller associated with the action
    return front_controller()

# authenticate-user
@app.route('/authenticate-user', methods=['POST'])
def authenticate_user() -> tuple:
    # execute the controller associated with the action
    return front_controller()

# calculate-tax
@app.route('/calculate-tax', methods=['POST'])
def calculate_tax() -> tuple:
    # execute the controller associated with the action
    return front_controller()

# Calculate taxes in batches
@app.route('/calculate-taxes', methods=['POST'])
def calculate_taxes():
    # Execute the controller associated with the action
    return front_controller()

# list-simulations
@app.route('/list-simulations', methods=['GET'])
def list_simulations() -> tuple:
    # execute the controller associated with the action
    return front_controller()

# delete-simulation
@app.route('/delete-simulation/<int:number>', methods=['GET'])
def delete_simulation(number: int) -> tuple:
    # execute the controller associated with the action
    return front_controller()

# end-session
@app.route('/end-session', methods=['GET'])
def end_session() -> tuple:
    # execute the controller associated with the action
    return front_controller()

# display-tax-calculation
@app.route('/display-tax-calculation', methods=['GET'])
def display_tax_calculation() -> tuple:
    # execute the controller associated with the action
    return front_controller()

# get-admindata
@app.route('/get-admindata', methods=['GET'])
def get_admindata() -> tuple:
    # execute the controller associated with the action
    return front_controller()

def execute(name: str):
    # session secret key
    app.secret_key = os.urandom(12).hex()
    # Flask-Session
    app.config.update(SESSION_TYPE='redis',
                      SESSION_REDIS=config['redis_client'])
    Session(app)
    # When running the Flask application via a console script
    if name == '__main__':
        app.config.update(ENV="development", DEBUG=True)
        app.run(threaded=True)
  • line 9: the Flask application is instantiated;
  • line 12: the application configuration is not yet known when the script is written. It is only known at the time of execution;
  • lines 20–77: the application routes as defined in the previous version. This remains unchanged;
  • lines 14–18: all routes simply call the [front_controller] function. We have stripped this function of its original code. It now simply calls the web application’s main controller;
  • lines 79–89: [execute] is the function called by the [main] script to launch the web application;
  • line 81: the [flask_session] module uses Flask’s secret key;
  • lines 82–84: configuration of the [flask_session] module. This involves adding the keys [SESSION_TYPE, SESSION_REDIS] to the [app.config] configuration of the Flask application [app]:
  • [SESSION_TYPE]: the session type. There are several types. The [redis] type indicates that [flask_session] uses a [redis] server to store user sessions. Because of this, we must define the [SESSION_REDIS] key, which must be the reference of a Redis client;
  • line 85: the [Flask-Session] is associated with the Flask application;
  • lines 86–89: if the [name] parameter on line 79 is the string [__main__], then the Flask application is launched;

33.3. Refactoring the main controller

The code that was previously in the [front_controller] function of the [main] script has been moved to the main controller:

Image


# import dependencies
import threading
import time
from random import randint

from flask_api import status
from werkzeug.local import LocalProxy

from InterfaceController import InterfaceController
from Logger import Logger
from SendAdminMail import SendAdminMail

def send_adminmail(config: dict, message: str):
    # Send an email to the application administrator
    config_mail = config['parameters']['adminMail']
    config_mail["logger"] = config['logger']
    SendAdminMail.send(config_mail, message)

# Main controller of the application
class MainController(InterfaceController):
    def execute(self, request: LocalProxy, session: LocalProxy, config: dict) -> (dict, int):
        # process the request
        logger = None
        action = None
        response_type1 = None
        try:
            # retrieve the path elements
            params = request.path.split('/')

            # the action is the first element
            action = params[1]

            # no errors initially
            error = False

            # the session type must be known before certain actions
            response_type1 = session.get('response_type')
            if type_response1 is None and action != "init-session":
                # log the error
                result = {"action": action, "status": 101,
                            "response": ["No session in progress. Start with action [init-session]"]}
                error = True

            # logger
            logger = Logger(config['parameters']['logsFilename'])

            # store it in a configuration associated with the thread
            thread_config = {"logger": logger}
            thread_name = threading.current_thread().name
            config[thread_name] = {"config": thread_config}

            # log the request
            logger.write(f"[MainController] request: {request}\n")

            # We terminate the thread if requested
            sleep_time = config['parameters']['sleep_time']
            if sleep_time != 0:
                # The pause is random so that some threads are interrupted and others are not
                random = randint(0, 1)
                if random == 1:
                    # log before pause
                    logger.write(f"[front_controller] paused the thread for {sleep_time} second(s)\n")
                    # pause
                    time.sleep(sleep_time)

            # For certain actions, you must be authenticated
            user = session.get('user')
            if not error and user is None and action not in ["init-session", "authenticate-user"]:
                # log the error
                result = {"action": action, "status": 101,
                            "response": [f"action [{action}] requested by unauthenticated user"]}
                error = True

            # Are there any errors?
            if error:
                # The request is invalid
                status_code = status.HTTP_400_BAD_REQUEST
            else:
                # execute the controller associated with the action
                controller = config['mvc']['controllers'][action]
                result, status_code = controller.execute(request, session, config)

        except BaseException as exception:
            # other (unexpected) exceptions
            result = {"action": action, "status": 131, "response": [f"{exception}"]}
            status_code = status.HTTP_400_BAD_REQUEST

        finally:
            pass

        # Log the result sent to the client
        log = f"[MainController] {result}\n"
        logger.write(log)

        # Was there a fatal error?
        if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
            # Send an email to the application administrator
            send_adminmail(config, log)

        # Determine the desired response type
        type_response2 = session.get('typeResponse')
        if type_response2 is None and type_response1 is None:
            # The session type has not yet been set—it will be JSON
            type_response = 'json'
        elif type_response2 is not None:
            # the response type is known and in the session
            type_response = type_response2
        else:
            # otherwise, continue using type_response1
            type_response = type_response1

        # build the response to send
        response_builder = config['mvc']['responses'][type_response]
        response, status_code = response_builder \
            .build_http_response(request, session, config, status_code, result)

        # close the log file if it was opened
        if logger:
            logger.close()

        # send the HTTP response
        return response, status_code

All of this code has been covered at one time or another.

33.4. Handling encrypted passwords

To handle encrypted passwords, we’ll use the [passlib] module, which we install from a PyCharm terminal:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\packages>pip install passlib
Collecting passlib

Here is an example script that encrypts the password passed to it as a parameter:

Image

The [create_hashed_password] script is as follows (https://passlib.readthedocs.io/en/stable/):


import sys

# encryption function
from passlib.hash import pbkdf2_sha256

# wait for the password to be hashed
syntax = f"{sys.argv[0]} password"
error = len(sys.argv) != 2
if error:
    print(f"syntax: {syntax}")
    sys.exit()
else:
    password = sys.argv[1]

# encrypt the password
hash = pbkdf2_sha256.hash(password)
print(f"Encrypted version of [{password}] = {hash}")

# verification
correct = pbkdf2_sha256.verify(password, hash)
print(correct)
  • line 16: the password passed as a parameter is encrypted;
  • line 20: we compare the password [password] passed as a parameter to its encrypted version [hash]. The [verify] function encrypts the password [password] and compares the resulting encrypted string to [hash]. Returns True if the two strings are equal;

The script above allows us to obtain the encrypted version of the password [admin]:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/impots/http-servers/08/passlib/create_hashed_password.py admin
encrypted version of [admin] = $pbkdf2-sha256$29000$fU9pTendO6c0ZoyR8r5Xqg$5ZXywIUnbMfN2hPnBaefiuqWjEbmAY.Lu06i4dwcnek
True

Line 2, the value we put in the [parameters] script:


        "users": [
            {
                "login": "admin",
                "password": "$pbkdf2-sha256$29000$fU9pTendO6c0ZoyR8r5Xqg$5ZXywIUnbMfN2hPnBaefiuqWjEbmAY.Lu06i4dwcnek"
            }
        ],

The authentication controller [AuthentifierUtilisateurController] evolves as follows:


from passlib.handlers.pbkdf2 import pbkdf2_sha256

            # we verify the validity of the (user, password) pair
            users = config['parameters']['users']
            i = 0
            nbusers = len(users)
            found = False
            while not found and i < nbusers:
                found = user == users[i]["login"] and pbkdf2_sha256.verify(password, users[i]["password"])
                i += 1
            # Found?
            if not found:

33.5. Tests

In addition to testing with a browser, you can also use the clients in the [http-servers/07] folder written for version 12. They should also work for version 13:

Image

  • in [1], all three clients should work;
  • in [2], both tests should work;