Skip to content

35. Application exercise: version 15

35.1. Introduction

This version aims to resolve issues related to refreshing the application’s pages in the browser (F5). Let’s take an example. The user has just deleted the simulation with id=3:

Image

After deletion, the URL in the browser is [/delete-simulation/3/…]. If the user refreshes the page (F5), the URL [1] is replayed. We therefore request the deletion of the simulation with id=3 again. The result is as follows:

Image

Here is another example. The user has just calculated a tax:

  • in [1], the URL [/calculate-tax] that was just queried with a POST request; Image

If the user refreshes the page (F5), they receive a warning message:

Image

The page refresh operation re-executes the last request made by the browser, in this case a [POST /calculate-tax]. When asked to re-execute a POST, browsers display a warning similar to the one above. This warns that it is re-executing an action that has already been performed. Suppose this POST completed a purchase; it would be unfortunate to repeat it.

Additionally, we will limit the user’s ability to type URLs into their browser. Let’s take one of the previous views as an example:

Image

The links provided by this page are:

  • [List of simulations] associated with the URL [/lister-simulations];
  • [End of session] associated with the URL [/end-session];
  • [Submit] associated with the URL (not shown above) [/calculate-tax];

When the tax calculation view is displayed, we will only accept the actions [/list-simulations, /end-session, /calculate-tax]. If the user enters another action in their browser, an error will be thrown. We will perform this type of validation for all four views of the application.

We propose to solve the page refresh issue as follows:

  • we will distinguish between two types of actions:
    • ADS (Action Do Something) actions that modify the application’s state. ADS actions generally have parameters in the URL or the request body;
    • ASV (Action Show View) actions that display a view without changing the application’s state. There will be as many ASV actions as there are views V. ASV actions have no parameters;
  • Until now, ADS actions were executed and then completed by displaying a view V after preparing its model M. From now on, they will place the view’s model M into the session and instruct the browser to redirect to the ASV action responsible for displaying the view V;
  • V views will only be displayed following an ASV action. They will retrieve their model from the session;

The advantage of this method is that the browser will display the ASV URL in its address bar. Refreshing the page will then re-execute the ASV action. This action does not modify the application’s state and uses a session model. Therefore, the same page will be re-displayed without any side effects. Finally, due to the redirects, the user will only see ASV action URLs in their browser and will feel as though they are navigating from page to page;

35.2. Implementation

Image

The [impots/http-servers/10] directory is initially created by copying the [impots/http-servers/09] directory. It is then modified.

35.2.1. The new routes

Image

In both the [routes_with_csrftoken] and [routes_without_csrftoken] files, we need to create the four routes for the four ASV actions that display the four views. The other routes remain unchanged.

In [routes_with_csrftoken]:


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

# display-authentication-view
@app.route('/display-authentication-view/<string:csrf_token>', methods=['GET'])
def display_authentication_view(csrf_token: str) -> tuple:
    # execute the controller associated with the action
    return front_controller()

# display-simulation-list-view
@app.route('/display-simulation-list-view/<string:csrf_token>', methods=['GET'])
def display_simulation_list_view(csrf_token: str) -> tuple:
    # execute the controller associated with the action
    return front_controller()

# display-error-list-view
@app.route('/display-error-list-view/<string:csrf_token>', methods=['GET'])
def display_error_list_view(csrf_token: str) -> tuple:
    # execute the controller associated with the action
    return front_controller()

Lines 1–23: We have created four routes for four ASV actions:

  • [/display-authentication-view], line 8, displays the authentication view;
  • [/display-tax-calculation-view], line 2, displays the tax calculation view;
  • [/display-simulation-list-view], line 14, displays the simulation view;
  • [/display-error-list-view], line 20, displays the unexpected errors view;

We do the same in the [routes_with_csrftoken] file for routes without a token:


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

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

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

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

35.2.2. The new controllers

Image

The [AuthenticationViewController] executes the ASV action [/display-authentication-view]:


from flask_api import status
from werkzeug.local import LocalProxy

from InterfaceController import InterfaceController

class ShowAuthenticationViewController(InterfaceController):

    def execute(self, request: LocalProxy, session: LocalProxy, config: dict) -> (dict, int):
        # retrieve the path elements
        params = request.path.split('/')
        action = params[1]

        # change view - just set a status code
        return {"action": action, "status": 1100, "response": ""}, status.HTTP_200_OK

We mentioned that ASV actions have no parameters and do not modify the application’s state. We simply display the desired view by setting a status code on line 14.

The [AfficherVueCalculImpotController] controller executes the ASV action [/afficher-vue-calcul-impot]:


from flask_api import status
from werkzeug.local import LocalProxy

from InterfaceController import InterfaceController

class DisplayTaxCalculationViewController(InterfaceController):

    def execute(self, request: LocalProxy, session: LocalProxy, config: dict) -> (dict, int):
        # retrieve the path elements
        params = request.path.split('/')
        action = params[1]

        # change view - just set the status code
        return {"action": action, "status": 1400, "response": ""}, status.HTTP_200_OK

The [AfficherVueListeSimulationsController] controller executes the ASV action [/afficher-vue-liste-simulations]:


from flask_api import status
from werkzeug.local import LocalProxy

from InterfaceController import InterfaceController

class DisplaySimulationListViewController(InterfaceController):

    def execute(self, request: LocalProxy, session: LocalProxy, config: dict) -> (dict, int):
        # retrieve the path elements
        params = request.path.split('/')
        action = params[1]

        # change view - just set a status code
        return {"action": action, "status": 1200, "response": ""}, status.HTTP_200_OK

The [AfficherVueListeErreursController] controller executes the ASV action [/afficher-vue-liste-erreurs]:


from flask_api import status
from werkzeug.local import LocalProxy

from InterfaceController import InterfaceController

class DisplayErrorListViewController(InterfaceController):

    def execute(self, request: LocalProxy, session: LocalProxy, config: dict) -> (dict, int):
        # retrieve the path elements
        params = request.path.split('/')
        action = params[1]

        # change view - just set a status code
        return {"action": action, "status": 1300, "response": ""}, status.HTTP_200_OK

Let’s summarize the status codes:

  • code 1100 should display the authentication view;
  • code 1400 should display the tax calculation view;
  • code 1200 should display the simulation view;
  • code 1300 should display the unexpected errors view;

35.2.3. The new MVC configuration

Because it had become too large, the MVC configuration has been split into four files:

  • [controllers]: the list of C controllers for the MVC application;
  • [ads_actions]: lists the ADS (Action Do Something) actions;
  • [asv_actions]: lists the ASV (Action Show View) actions;
  • [responses]: lists the application’s HTTP response classes;

Let’s start with the simplest one, the HTTP response file [responses]:


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

    # HTTP responses
    from HtmlResponse import HtmlResponse
    from JsonResponse import JsonResponse
    from XmlResponse import XmlResponse

    # Different response types (json, xml, html)
    responses = {
        "json": JsonResponse(),
        "html": HtmlResponse(),
        "xml": XmlResponse()
    }

    # Return the HTTP response dictionary
    return {
        # HTTP responses
        "responses": responses,
    }

No surprises here.

The [controllers] file is also unsurprising. We simply added the new controllers for the ASV actions.


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

    # the main controller
    from MainController import MainController

    # ADS action controllers
    from UserAuthController import UserAuthController
    from CalculateTaxController import CalculateTaxController
    from CalculateTaxesController import CalculateTaxesController
    from FinSessionController import FinSessionController
    from GetAdminDataController import GetAdminDataController
    from InitSessionController import InitSessionController
    from ListSimulationsController import ListSimulationsController
    from DeleteSimulationController import DeleteSimulationController
    from DisplayTaxCalculationController import DisplayTaxCalculationController

    # ASV action controllers
    from DisplayTaxCalculationViewController import DisplayTaxCalculationViewController
    from DisplayAuthenticationViewController import DisplayAuthenticationViewController
    from DisplayErrorListViewController import DisplayErrorListViewController
    from DisplaySimulationListViewController import DisplaySimulationListViewController

    # Authorized actions and their controllers
    controllers = {
        # initialization of a calculation session
        "init-session": InitSessionController(),
        # user authentication
        "authenticate-user": AuthenticateUserController(),
        # link to tax calculation view
        "display-tax-calculation": DisplayTaxCalculationController(),
        # calculate tax in individual mode
        "calculate-tax": CalculateTaxController(),
        # tax calculation 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(),
        # retrieve data from the tax administration
        "get-admindata": GetAdminDataController(),
        # main controller
        "main-controller": MainController(),
        # display the authentication view
        "display-authentication-view": DisplayAuthenticationViewController(),
        # display the tax calculation view
        "display-tax-calculation-view": DisplayTaxCalculationViewController(),
        # display the simulation view
        "display-simulation-list-view": DisplaySimulationListViewController(),
        # Display the errors view
        "display-error-list-view": DisplayErrorListViewController()
    }

    # Return the controller configuration
    return {
        # controllers
        "controllers": controllers,
    }

The [asv_actions] configuration for ASV actions is as follows:


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

    # HTML views and their models depend on the state returned by the controller
    # ASV actions (Action Show view)
    asv = [
        {
            # Authentication view
            "states": [
                1100,  # /display-authentication-view
            ],
            "view_name": "views/authentication-view.html",
        },
        {
            # tax calculation view
            "statements": [
                1400,  # /display-tax-calculation-view
            ],
            "view_name": "views/tax-calculation-view.html",
        },
        {
            # simulation list view
            "statements": [
                1200,  # /display-simulation-list-view
            ],
            "view_name": "views/simulation-list-view.html",
        },
        {
            # error list view
            "statuses": [
                1300,  # /display-error-list-view
            ],
            "view_name": "views/error-list.html",
        },
    ]

    # return the ASV configuration
    return {
        # views and templates
        "asv": asv,
    }
  • The [asv_actions] file contains the four new actions, whose functionality is summarized below:
    • they have no parameters;
    • they display a specific view whose template is in session;
  • The [asv] list in lines 6–35 associates a view with each ASV action;

The [ads_actions] file contains the ADS actions:


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

    # view models
    from ModelForAuthentificationView import ModelForAuthentificationView
    from ModelForTaxCalculationView import ModelForTaxCalculationView
    from ModelForErrorView import ModelForErrorView
    from ModelForSimulationListView import ModelForSimulationListView

    # ADS (Action Do Something) actions
    ads = [
        {
            "statuses": [
                400,  # /successful session end
            ],
            # redirect to ADS action
            "to": "/init-session/html",
        },
        {
            "status": [
                700,  # /init-session - success
                201,  # /authenticate-user failure
            ],
            # redirect to ASV action
            "to": "/display-authentication-view",
            # template for the following view
            "model_for_view": ModelForAuthentificationView()
        },
        {
            "statuses": [
                200,  # /authenticate-user success
                300,  # /calculate-tax success
                301,  # /calculate-tax failure
                800,  # /display-tax-calculation link
            ],
            # redirect to ASV action
            "to": "/display-tax-calculation-view",
            # template for the following view
            "model_for_view": ModelForCalculImpotView()
        },
        {
            "status": [
                500,  # /list-successful-simulations
                600,  # /delete-simulation success
            ],
            # redirect to ASV action
            "to": "/display-simulation-list-view",
            # model for the following view
            "model_for_view": ModelForListeSimulationsView()
        },
    ]

    # unexpected errors view
    view_errors = {
        # Redirect to ASV action
        "to": "/display-error-list-view",
        # model for the following view
        "model_for_view": ModelForErrorsView()
    }

    # Return the MVC configuration
    return {
        # ADS actions
        "ads": ads,
        # view for unexpected errors
        "error_view": error_view,
    }
  • lines 11–51: the list of ADS (Action Do Something) actions. All actions from previous versions are included. However, their behavior has changed:
    • they do not display a view V. They only prepare the model M for this view V;
    • they request the display of view V via a redirect to the ASV action associated with view V;
  • not all ADS actions lead to a redirection to an ASV action: lines 12–18, the ADS action [/fin-session] leads to a redirection to the ADS action [/init-session/html]. To distinguish between ADS -> ADS and ADS -> ASV redirects, we can use the [model_for_view] template. This does not exist for ADS -> ADS redirects;

The [main/config] file, which contains all configurations, changes as follows:


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

    # application settings
    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 for the [web] layer
    config['mvc'] = {}

    # [web] layer controller configuration
    import controllers
    config['mvc'].update(controllers.configure(config))

    # ASV actions (Action Show View)
    import asv_actions
    config['mvc'].update(asv_actions.configure(config))

    # ADS (Action Do Something) actions
    import ads_actions
    config['mvc'].update(ads_actions.configure(config))

    # HTTP response configuration
    import responses
    config['mvc'].update(responses.configure(config))

    # return the configuration
    return config

35.2.4. The new models

Image

The models will generate new information. Let’s take, for example, the authentication view model:


from flask import Request
from werkzeug.local import LocalProxy

from AbstractBaseModelForView import AbstractBaseModelForView

class ModelForAuthenticationView(AbstractBaseModelForView):

    def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, result: dict) -> dict:
        # encapsulate the page data in a model
        model = {}
        

        # CSRF token
        model['csrf_token'] = super().get_csrftoken(config)

        # Possible actions from the view
        model['possible_actions'] = ["display-authentication-view", "authenticate-user"]

        # return the model
        return model
  • The new feature is on line 17: each model will generate a list of possible actions when the view V, for which it is the model M, is displayed. To see these actions, we need to return to the view V. In the case of the authentication view:

Image

  • we see that the authentication view offers only one action, that of the [Validate] button. This action is [/authenticate-user];

We wrote:


        # possible actions from the view
        model['possible_actions'] = ["display-authentication-view", "authenticate-user"]

In the view above, we see that if the user refreshes the view, action [1] [/display-authentication-view] will be replayed. It must therefore be authorized. We could have chosen not to authorize it, in which case the user would have encountered an error every time they reloaded the page. We determined that this was not desirable.

The possible actions are placed in the view model. We know that this model will be stored in the session.

We do this for each of the four views. The possible actions are then as follows:

Authentication view


model['possible_actions'] = ["display-authentication-view", "authenticate-user"]

Tax calculation view


# Available actions from the view
model['possible_actions'] = ["display-tax-calculation-view", "calculate-tax", "list-simulations", "end-session"]

Simulation list view


# Possible actions from the view
possible_actions = ["display-simulation-list-view", "display-tax-calculation", "end-session"]
if len(model['simulations']) != 0:
  possible_actions.append("delete-simulation")
model['possible_actions'] = possible_actions

Error list view


# possible actions from the view
model['possible_actions'] = ["display-error-list-view", "display-tax-calculation", "list-simulations", "end-session"]

35.2.5. The new main controller

Image

The [MainController] undergoes a few changes:


# Import dependencies
import threading
import time
from random import randint

from flask_api import status
from flask_wtf.csrf import generate_csrf, validate_csrf
from werkzeug.local import LocalProxy
from wtforms import ValidationError

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):
        # Processing the request
        type_response1 = None
        logger = None
        try:
            # retrieve the path elements
            params = request.path.split('/')

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

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

            

            # The /display-error-list-view action is special
            # no checks are performed, otherwise there is a risk of entering an infinite loop of redirects
            error = False
            if action != "display-error-list-view":
                # if error, [result] is the result to send to the client
                (error, result, response_type1) = MainController.check_action(params, session, config)

            # if no error - the action is executed
            if not error:
                # execute the controller associated with the action
                controller = config['mvc']['controllers'][action]
                result, status_code = controller.execute(request, session, config)

        except BaseException as exception:
            # (unexpected) exceptions
            result = {"action": action, "status": 131, "response": [f"{exception}"]}
            error = True

        finally:
            pass

        # runtime error
        if error:
            # invalid request
            status_code = status.HTTP_400_BAD_REQUEST

        if config['parameters']['with_csrftoken']:
            # add the csrf_token to the result
            result['csrf_token'] = generate_csrf()

        ….

        # send the HTTP response
        return response, status_code

    @staticmethod
    def check_action(params: list, session: LocalProxy, config: dict) -> (bool, dict, str):
        

        # Result of the method
        return error, result, response_type1
  • lines 39–44: the first checks are performed on the action. We’ll come back to this. The static method [MainController.check_action] returns a tuple of three elements:
    • [error]: True if an error was detected, False otherwise;
    • [result]: an error result if (error == True), None otherwise;
    • [response_type1]: the type (json, xml, html, None) of the session, as found in the session;
  • line 39: no check is performed if the action is the ASV action [display-error-list-view], which displays the list of errors. Indeed, if an error were found during this action, we would be redirected back to the [/display-error-list-view] action and enter an infinite loop of redirects;
  • The static method [check_action] is as follows:

    @staticmethod
    def check_action(params: list, session: LocalProxy, config: dict) -> (bool, dict, str):
        # retrieve the current action
        action = params[1]

        # no error and no result initially
        error = False
        result = None

        # The session type must be known before certain ADS 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

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

        # From a view, only certain actions are possible
        if not error and action != "init-session":
            # no actions are possible at this time
            possible_actions = None
            # retrieve the model for the future view from the session if it exists
            model = session.get('model')
            # if a model was found, retrieve its possible actions
            if model:
                possible_actions = model.get('possible_actions')
            # if we have a list of possible actions, check if the current action is among them
            if possible_actions and action not in possible_actions:
                # log the error
                result = {"action": action, "status": 151,
                            "response": [f"Incorrect action [{action}] in the current environment"]}
                error = True


        # error?
        if not error and config['parameters']['with_csrftoken']:
            # check the validity of the CSRF token
            # the csrf_token is the last element of the path
            csrf_token = params.pop()
            try:
                # An exception will be raised if the csrf_token is invalid
                validate_csrf(csrf_token)
            except ValidationError as exception:
                # invalid CSRF token
                result = {"action": action, "status": 121, "response": [f"{exception}"]}
                # Log the error
                error = True

        # result of the method
        return error, result, response_type1
  • line 2: the [check_action] method performs several checks on the validity of the current action;
  • lines 6–26, 45–57: previous versions already performed these checks;
  • lines 28–42: a new check is added. We verify whether the current action is possible given the application’s current state. If the current action is not possible, we generate a status code 151 (line 40), which ensures that the current action will be redirected to the unexpected errors view;

35.2.6. The new HTML response

Image

The current changes apply only to HTML sessions. JSON or XML sessions are not affected. The [HtmlResponse] class is updated as follows:


# dependencies

from flask import make_response, redirect, render_template
from flask.wrappers import Response
from flask_api import status
from flask_wtf.csrf import generate_csrf
from werkzeug.local import LocalProxy

from InterfaceResponse import InterfaceResponse

class HtmlResponse(InterfaceResponse):

    def build_http_response(self, request: LocalProxy, session: LocalProxy, config: dict, status_code: int,
                            result: dict) -> (Response, int):
        # The HTML response depends on the status code returned by the controller
        status = result["status"]

        # check if the status was generated by an ASV action
        # in which case, a view must be displayed
        asv_configs = config['mvc']["asv"]
        found = False
        i = 0
        # iterate through the list of views
        nb_views = len(asv_configs)
        while not found and i < nb_views:
            # view #i
            asv_config = asv_configs[i]
            # states associated with view #i
            states = asv_config["states"]
            # Is the state being searched for among the states associated with view #i
            if state in states:
                found = True
            else:
                # next view
                i += 1

        # found?
        if found:
            # this is an ASV action - we need to display a view whose model is already in session
            # generate the HTML code for the response
            html = render_template(asv_config["view_name"], template=session['template'])
            # we construct the HTTP response
            response = make_response(html)
            response.headers['Content-Type'] = 'text/html; charset=utf-8'
            # Return the result
            return response, status_code

        # Not found - this is an ADS action status code
        # This will be followed by a redirect
        redirected = False
        for ads in config['mvc']['ads']:
            # states requiring a redirect
            states = ads["states"]
            if status in statuses:
                # there is a redirect
                redirected = True
                break
        # Redirect dictionary for unexpected errors
        if not redirected:
            ads = config['mvc']['error_view']

        # Is this a redirect to an ASD or ASV action?
        # if there is a template, then it is a redirect to an ASV action
        # we must then calculate the model for the V view that will be displayed by the ASV action
        model_for_view = ads.get("model_for_view")
        if model_for_view:
            # Calculate the model for the next view
            model = model_for_view.get_model_for_view(request, session, config, result)
            # the model is set in the session for the next view
            session['model'] = model

            # now we need to generate the redirect URL, including the CSRF token if required
        if config['parameters']['with_csrftoken']:
            csrf_token = f"/{generate_csrf()}"
        else:
            csrf_token = ""
        
        # Redirect response
        return redirect(f"{ads['to']}{csrf_token}"), status.HTTP_302_FOUND
  • lines 18–35: check if the status returned by the last executed action is that of an ASV action;
  • lines 36–46: if so, the V view associated with the ASV action is displayed using the model found in the session associated with the [‘model’] key;
  • lines 48-60: when we reach this point, we know that the status produced by the last executed action is that of an ADS action. It will then trigger a redirect. We search the configuration file for its definition;
  • line 62: when we reach this point, we have the configuration for the redirect to be performed. There are two cases:
    • it is a redirection to another ADS action. In this case, there is no view model to calculate;
    • it is a redirect to an ASV action. In this case, a view model must be calculated (lines 67–68). This model is then added to the session (line 70);
  • lines 72–76: the redirect URL is calculated;
  • lines 78–79: the redirect response is sent to the client;

35.3. Tests

Perform the following tests using a browser:

  • Use the application as usual. Verify that the only URLs displayed by the browser are ASV URLs [/display-view-view_name];
  • refresh the pages (F5) and verify that the same page is displayed again. There are no side effects;

Additionally, use the client [impots/http-clients/09]. Since the changes made only affect HTML sessions, the clients [main, main2, main3, Test1HttpClientDaoWithSession, Test2HttpClientDaoWithSession] should continue to function.

Now let’s look at a case where an action is impossible. The following view is displayed:

Image

Instead of [1], enter the URL [/delete-simulation/1]. The action [/delete-simulation] is not among the actions offered by the view, which are actions 1–4. It will therefore be rejected. The server response is as follows:

Image