miguelgrinberg / turbo-flask

Integration of Hotwire's Turbo library with Flask.
MIT License
301 stars 35 forks source link

Experimenting with Turbo-Flask functionality on Multiple Webpages #14

Open OliverSparrow opened 2 years ago

OliverSparrow commented 2 years ago

0. Note:

Hello,

I'm still relatively inexperienced in Flask, though I've been using for a while now. Also, the HTML & CSS templates for the webpages can be found here:

TempA.txt TempB.txt

1. The Premise:

Recently, as part of a project, I needed to update a webpage dynamically when a remote trigger was activated.

Let's say there are two webpages: Webpage A & Webpage B. Both A & B are running at the same time, and have been set some buttons within forms. These buttons are assigned values and, when clicked, these values are sent to the Flask app & stored in some database.

UI Design of Webpages

Depending on the selected button, the text on the opposite webpage should dynamically update to display the value of the button. That is, if "Btn 1" on Webpage A was clicked, it should dynamically update the \

element in Webpage B to say "Btn 1".

So far, the webpages were able interact in such a way using a database, by storing the latest button values and feeding them to each webpage. However, the values were not displayed on the opposite webpage unless it was refreshed manually. Quite a few sources suggested using Sockets as a way to update webpages in real-time. From what I could understanding, however, that may be a bit excessive for tasks like this. Thus, Turbo-Flask was recommended as a solution, as it seemed to sidestep the process of manually setting up a secondary Socket server.

2. The Code:

I don't know a lot about client and server side interactions, or the turbo.js resource. Using the documentation, though, I implemented the following solution:

from flask import Flask, request, render_template
from turbo_flask import Turbo
import os

def create_app():
    app = Flask(__name__)
    app.config.from_mapping(
        SECRET_KEY=os.urandom(16),
    )
    turbo = Turbo(app)

    @app.route("/A", methods=["GET", "POST"])
    def A():
        if request.method == "POST":
            value = request.form["option"]

            if turbo.can_stream():
                turbo.stream(
                    turbo.update(value, "bTextB"),
                )
                print("Pushed!")
            else:
                print("Can't stream updates")

        return render_template("TempA.html", bText="Bread Text")

    @app.route("/B", methods=["GET", "POST"])
    def B():
        if request.method == "POST":
            value = request.form["option"]

            if turbo.can_stream():
                turbo.stream(
                    turbo.update(value, "bTextA"),
                )
                print("Pushed!")
            else:
                print("Can't Stream updates!")

        return render_template("TempB.html", bText="Bread Text")

    return app

3. The Error(s):

When running the following code, however, the text elements stayed the same. Instead, the following error popped up: "GET /turbo-stream HTTP/1.1" 400 -

The console did output a "Pushed!" message, however, meaning the turbo could stream and attempted to do so. When inspecting the Firefox console, the console reported the following errors:

  • Firefox can’t establish a connection to the server at ws://127.0.0.1:5000/turbo-stream. (Error in /A)
  • Error: Form responses must redirect to another location. (Error in turbo.js)

4. The Question(s):

This leads me to the following questions:

  1. Is Turbo-Flask the best Flask extension for tasks like this? Should I be considering other extensions, such as Sockets?
  2. Based on the current implementation, would the code actually work? Is it possible to add such inter-webpage functionality in future, or would it be too niche?
  3. What could I do to fix my current code?

I apologize in advance if I've missed any details or have added to much.

miguelgrinberg commented 2 years ago

First of all, the Firefox issue is I think a known problem that has been fixed a while ago in Werkzeug, but hasn't been released yet. You may want to install the main branch of Werkzeug to see if the problem goes away.

Now on to your questions:

I can't really tell you it is the "best" extension. There are really too many ways to do this, and which one is the best depends on your personal preferences and experience. It is an adequate solution, that much I can tell you.

I think your main (maybe only) problem is that you are not returning the streams. When you call turbo.stream() to generate an update stream, you have to return that as the response. You are generating the stream, then discarding it, and then returning the original page template, which invalidates all the work Turbo-Flask is doing.

OliverSparrow commented 2 years ago

Progress Update:

Thank you for the quick response, Miguel!

After installing the Werkzeug library via GitHub (as you suggested), the Firefox error seemed to be resolved! However, returning the streams as is didn't seem to update the other webpage. I'm supposing that the Streams' scopes are restricted to the assigned App Route, so it can't update the second webpage dynamically.

Instead, I've tried using a database to track button presses from each webpage. Using the database values, the application compares the values stored into the \

with the DB value. If the value is different, it changes the \
element to the new value, and the server keeps track of the new element value.

Below are the updates to the previous code, using the same HTML pages but now introduces an SQLite Database.

# Set a global 'dict' variable to track the values of div elements
session = {'bTextA': None,
           'bTextB': None}

def create_app():
    ...

    from .DB import init_app, get_db

    init_app(app)
    turbo = Turbo(app)

    @app.route("/A", methods=["GET", "POST"])
    def A():
        global session
        db = get_db()
        bTextA = db.execute("SELECT OptnVal FROM Options WHERE toTable = 'A' ORDER BY OptionID DESC").fetchone()
        print(bTextA['OptnVal'])

        if session['bTextA'] != bTextA['OptnVal']:
            if turbo.can_stream():
                session['bTextA'] = bTextA['OptnVal']
                print("Pushed!")
                return turbo.stream(
                    turbo.update(bTextA['OptnVal'], "bTextA"),
                )
            else:
                print("Can't stream updates")

        if request.method == "POST":
            value = request.form["option"]
            db.execute("INSERT INTO Options(OptnVal, toTable) VALUES(?, 'B')", (value,))
            db.commit()

        return render_template("TempA.html", bText="Bread Text")

    @app.route("/B", methods=["GET", "POST"])
    def B():
        global session
        db = get_db()
        bTextB = db.execute("SELECT OptnVal FROM Options WHERE toTable = 'B' ORDER BY OptionID DESC").fetchone()
        print(bTextB['OptnVal'])

        if session['bTextB'] != bTextB['OptnVal']:
            session['bTextB'] = bTextB['OptnVal']
            if turbo.can_stream():
                print("Pushed!")
                return turbo.stream(
                    turbo.update(bTextB['OptnVal'], "bTextB"),
                )
            else:
                print("Can't stream updates!")

        if request.method == "POST":
            value = request.form["option"]
            db.execute("INSERT INTO Options (OptnVal, toTable) VALUES(?, 'A')", (value,))
            db.commit()

        return render_template("TempB.html", bText="Bread Text")

    return app

Note: There isn't much to the SQLite Database Schema. There are three fields:

  • OptionID: The Primary Key that Auto-Increments whenever it receives a new button press.
  • OptnVal: The value of the button press.
  • toTable: This field tracks which webpage's element must be updated.

I'm aware this is a quite rushed functionality implementation, based on the number of bugs present:

  1. The \
    still don't refresh dynamically, defeating the whole purpose of the implementation.
  2. The \
    don't update unless another action occurs on the webpage, causing the webpage to update.
  3. If the webpages were refreshed manually, the database & updating system breaks down for a while.
  4. When setting the original "bText" values to the recorded session button values, the system breaks down significantly.

I hope to understand why does the application break in the ways it does, and if there are ways to resolving these issues.

For anyone wanting to recreate the .DB Blueprint, use the code outlined in the Flask Documentation's Tutorial for Accessing Databases: https://flask.palletsprojects.com/en/2.0.x/tutorial/database/

PS. Is it possible to add some form of multi-webpage modification functionality to Turbo-Flask in some way, so that it works independently of a database? Something along of the lines of: turbo.update( content, target, template [defaults to the scoped route]) I understand if this is a niche subject, or if it's too much of an effort to implement such a functionality.

Also, I know could use a meta refresh HTML tag, but that would refresh the entire page. So...

miguelgrinberg commented 2 years ago

This may sound bad, but I think you are overcomplicating this. Let's go back to the original question. Unfortunately since there is no code I can run, I can only make suggestions that I think will work. I now realize that I was close on my suggestion to return the turbo.stream() response, but really what you need is slightly different.

There are two operations in the Turbo Stream module, turbo.stream() and turbo.push(). The stream operation sends a streamed response to the originating client only, which I now realize is not what you need. The push operation sends an update to one, a set or all connected clients. So basically what you need is to push an update, which will trigger all connected clients to update. Something like this:

   @app.route("/A", methods=["GET", "POST"])
   def A():
       if request.method == "POST":
           value = request.form["option"]

           if turbo.can_push():
               turbo.push(
                   turbo.update(value, "bTextB"),
               )
               print("Pushed!")
           else:
               print("Can't push updates")

       return render_template("TempA.html", bText="Bread Text")

Once again, I'm writing this by memory, so it may need some polishing to make it into a fully working solution.

OliverSparrow commented 2 years ago

Progress Update:

It works now!!! Once again, thank you for the quick response, Miguel! The webpages interact with each other as expected, updating itself when a button from the opposite webpage is clicked. Below is the final code:

from flask import Flask, request, render_template
from turbo_flask import Turbo
import os

def create_app():
    # Set up Flask application & Configure it
    app = Flask(__name__)
    app.config.from_mapping(
        SECRET_KEY=os.urandom(16),
        DATABASE="TestApp/TestConnect.db"
    )

    # Initialise the Turbo object within the Flask app
    turbo = Turbo(app)

    # Execute the following code, whenever the "/A" (Webpage A) route is called from the domain
    @app.route("/A", methods=["GET", "POST"])
    def A():

        # Store the value of the POSTed button input, whenever any button is pressed
        if request.method == "POST":
            value = request.form["option"]

            # Push an update to all HTML templates,
            # that updates all <div> element with an id="bTextB" to received button input
            if turbo.can_push():
                print("Pushed!")
                turbo.push(
                    turbo.update(value, "bTextB"),
                )
            # Report if there are any issues with pushing using turbo
            else:
                print("Can't push updates to B!")

        # Return the original webpage template for Webpage A, with some default text
        return render_template("TempA.html", bText="Bread Text")

    # Execute the following code, whenever the "/A" (Webpage A) route is called from the domain
    @app.route("/B", methods=["GET", "POST"])
    def B():

        # Store the value of the POSTed button input, whenever any button is pressed
        if request.method == "POST":
            value = request.form["option"]

            # Push an update to all HTML templates,
            # that updates all <div> element with an id="bTextA" to received button input
            if turbo.can_push():
                print("Pushed!")
                turbo.push(
                    turbo.update(value, "bTextA"),
                )
            # Report if there are any issues with pushing using turbo
            else:
                print("Can't push updates to A!")

        # Return the original webpage template for Webpage B, with some default text
        return render_template("TempB.html", bText="Butter Text")

    return app

A minor downside to this implementation, however, is the case when one of the webpages is refreshed. In that situation, the webpage resets to the default text outlined in the code, instead of preserving the previous button value. This is because the routes of both Webpages A & B are set to render a template with static text in the beginning. Since the webpages rerun the application script whenever they are refreshed, the Webpages reset themselves accordingly.

A simple(ish) fix to this, however, is to use a database to track button presses. That way, instead of using some default text for the original web template value, the text can be preset to the latest button press before the refresh. The final code - if a database similar to the one previously outlined was used - would then look like the following:

from flask import Flask, request, render_template
from turbo_flask import Turbo
import os

def create_app():
    # Set up Flask application & Configure it
    app = Flask(__name__)
    app.config.from_mapping(
        SECRET_KEY=os.urandom(16),
        DATABASE="TestApp/TestConnect.db"
    )

    # Import the DB module
    # (see Flask Documentation for how to set up this module: https://flask.palletsprojects.com/en/2.0.x/tutorial/database/)
    from .DB import init_app, get_db
    init_app(app)
    # Initialise the Turbo object within the Flask app
    turbo = Turbo(app)

    # Execute the following code, whenever the "/A" (Webpage A) route is called from the domain
    @app.route("/A", methods=["GET", "POST"])
    def A():

        # Access the database, and record the latest button value to be displayed on the webpage
        db = get_db()
        bTextA = db.execute("SELECT OptnVal FROM Options WHERE toTable = 'A' ORDER BY OptionID DESC").fetchone()

        # Store the value of the POSTed button input, whenever any button is pressed
        if request.method == "POST":
            value = request.form["option"]

            # Push an update to all HTML templates,
            # that updates all <div> element with an id="bTextB" to received button input
            if turbo.can_push():
                # Track the button value and the intended Webpage destination
                db.execute("INSERT INTO Options(OptnVal, toTable) VALUES(?, 'B')", (value,))
                db.commit()
                print("Pushed!")
                turbo.push(
                    turbo.update(value, "bTextB"),
                )
            # Report if there are any issues with pushing using turbo
            else:
                print("Can't push updates to B!")

        # Return the original webpage template for Webpage A, with the latest recorded value from the database
        return render_template("TempA.html", bText=bTextA['OptnVal'])

    # Execute the following code, whenever the "/A" (Webpage A) route is called from the domain
    @app.route("/B", methods=["GET", "POST"])
    def B():

        # Access the database, and record the latest button value to be displayed on the webpage
        db = get_db()
        bTextB = db.execute("SELECT OptnVal FROM Options WHERE toTable = 'B' ORDER BY OptionID DESC").fetchone()

        # Store the value of the POSTed button input, whenever any button is pressed
        if request.method == "POST":
            value = request.form["option"]

            # Push an update to all HTML templates,
            # that updates all <div> element with an id="bTextA" to received button input
            if turbo.can_push():
                # Access the database, and record the latest button value to be displayed on the webpage
                db.execute("INSERT INTO Options (OptnVal, toTable) VALUES(?, 'A')", (value,))
                db.commit()
                print("Pushed!")
                turbo.push(
                    turbo.update(value, "bTextA"),
                )
            # Report if there are any issues with pushing using turbo
            else:
                print("Can't push updates to A!")

        # Return the original webpage template for Webpage B, with the latest recorded value from the database
        return render_template("TempB.html", bText=bTextB['OptnVal'])

    return app

Although there may be more efficient methods to solve this issue, this solution required the least effort to implement, as it was set up during my previous response.

Final Questions:

  1. In what instances would turbo.stream() be preferred over the turbo.push() method?
  2. Using the .push() method updated the second webpage dynamically, without calling a full page refresh. Would this method have affected any other webpages, so long as those webpages contains elements that had the same "target id" as the one specified in the method?
miguelgrinberg commented 2 years ago

Glad to hear this is working. Congrats!

Answers to your questions:

  1. The stream() method is used to return an optimized response to a POST request. Because it is an HTTP response, it goes only to the client that made the originating request. The best use case for this is when a form is submitted, you can return validation errors via stream().
  2. The push() method accepts a second argument that is optional, used to indicate who should receive the update. When this argument is omitted, all connected clients get it. If you have other clients that also use Turbo-Flask and happen to have an element with the same ID you are passing here, then they will also update. If you assign IDs to your users (see documentation) you can then pass the second argument, which can be the ID of a single client to target, or a list with all the IDs you want to get the push.
SlodeSoft commented 2 years ago

Hi,

Same uage, i think.

i've got a HTML page with return(render_template(...))

# Standard turbo-flask
def update_load():
    with app.app_context():
        while True:
            turbo.push(turbo.replace(render_template('info.html'), target='load'))
            time.sleep(0.2)
# [...]

@app.before_first_request
def before_first_request():
    threading.Thread(target=update_load).start()
            if turbo.can_push():
                turbo.push(turbo.replace(target='info.html', content={"total": "{:.2f}".format(total),
                                                                        "used": "{:.2f}".format(used),
                                                                        "free": free,
                                                                        "prctused": "{:.2f}".format(percentused),
                                                                        "proccount": proccount,
                                                                        "rytrsf": get_ry_info["progress"]}))
                time.sleep(0.7)
            else:
                print('Turbo pousse pas ! :( ')
            return render_template('info.html',
                                   title=title,
                                   stylecssprogbar='/static/cssprogbar.css',
                                   scriptjs="/static/progbar.js",
                                   total="{:.2f}".format(total),
                                   used="{:.2f}".format(used),
                                   free=free,
                                   prctused="{:.2f}".format(percentused),
                                   proccount=proccount,
                                   rytrsf=str(get_ry_info["progress"]))
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{ title }}</title>
  {{ turbo() }}
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
  <link rel="stylesheet" href="{{ stylecssprogbar }}">
</head>
<body>
<div id="load" class="load">
<text>
  <br>
  {{ used }} Go | {{ total }} Go au total.
  <br>
  {{ prctused }} % used.
</text>
<br><br>
<br>
<div class="progress">
  <div class="progress-bar progress-bar-danger"
       role="progressbar"
       aria-valuenow="{{ prctused }}"
       aria-valuemin="0" aria-valuemax="100" style="width:{{ prctused }}%">
    {{ prctused }} %
  </div>
</div>
{% if proccount > 0 %}
<div class="progress">
  <div class="progress-bar progress-bar-success progress-bar-striped active"
       role="progress-bar"
       area-valuenow="{{ rytrsf }}"
       area-valuemin="0" area-valuemax="100" style="width:{{ rytrsf }}%;">
    {{ rytrsf }} %
  </div>
</div>
{% endif %}
</div>
</body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
<script src="https://code.jquery.com/jquery-3.1.1.js"></script>
</html>

Result HTML :

image

My goal was to get a dynamic update without refresh page/frame, about usage and current transfer on DISK !

On DEBUG mode :

print('Turbo pousse pas ! :( ') # Translate = Turbo doesn't push ! :(

image

What's wrong ?

Thanks in advance !

miguelgrinberg commented 2 years ago

@SlodeSoft first of all, you seem to have an error in your template. Can you fix that to eliminate that as a possible contributor to the problem?

If turbo.can_push() returns false it means that in that request, the client cannot handle turbo stream updates. I think you may need to familiarize yourself with the turbo client-side documentation to understand why your usage is invalid. Turbo stream updates are accepted only as a response of a form POST, I don't see any POST requests in your log.

hsjdekoning commented 1 year ago

For reference / future readers. I got the example to work on Azure App Service Linux (Python 3.9)

Using the following line in the portal under Configuration >> General Settings >> Startup Command: gunicorn --bind=0.0.0.0 --workers=1 --threads=100 app:app

And the somewhat modified code for app.py:

from flask import Flask, request, render_template
from turbo_flask import Turbo
import os

app = Flask(__name__)
turbo = Turbo(app)

app.secret_key = os.getenv("APP_SECRET_KEY")

# Execute the following code, whenever the "/A" (Webpage A) route is called from the domain

@app.route("/A", methods=["GET", "POST"])
def A():

    # Store the value of the POSTed button input, whenever any button is pressed
    if request.method == "POST":
        value = request.form["option"]

        # Push an update to all HTML templates,
        # that updates all <div> element with an id="bTextB" to received button input
        if turbo.can_push():
            print("Pushed!")
            turbo.push(
                turbo.update(value, "bTextB"),
            )
        # Report if there are any issues with pushing using turbo
        else:
            print("Can't push updates to B!")

     # Return the original webpage template for Webpage A, with some default text
    return render_template("TempA.html", bText="Bread Text")

# Execute the following code, whenever the "/A" (Webpage A) route is called from the domain

@app.route("/B", methods=["GET", "POST"])
def B():

    # Store the value of the POSTed button input, whenever any button is pressed
    if request.method == "POST":
        value = request.form["option"]

        # Push an update to all HTML templates,
        # that updates all <div> element with an id="bTextA" to received button input
        if turbo.can_push():
            print("Pushed!")
            turbo.push(
                turbo.update(value, "bTextA"),
            )
        # Report if there are any issues with pushing using turbo
        else:
            print("Can't push updates to A!")

     # Return the original webpage template for Webpage B, with some default text
    return render_template("TempB.html", bText="Butter Text")

if __name__ == "__main__":
    app.run()

The secret key is set in the Portal under Configuration >> Application Settings