Multi-part form using Flask / WTForms

Question:

I have a multi-part form to generate – think similar workflow to a Shopping Cart where you have multiple “sections” (eg details, billing, payment etc) for the one form that display one at a time.

Key Details:

  • I have 3 sections to the form
  • The sections must be completed in sequence and Section 2 relies on information from Section 1
  • The form data is useless (eg will be thrown away) unless the person completes the FULL process (Section 1, 2 and 3).

Ways I’ve considered approaching this:

  • Using the one Route def and storing a value in request.args that tells me which “Section” I am at then render_template a different Form template depending on the section. This feels hacky…
  • Having a different Route and View for each section. This feels wrong and I’d have to stop people from going directly to Step 2 via URL
  • Putting all Sections on the one form, using Javascript to hide portions of the one form and moving between sections that way

What’s the best method to accomplish this in Flask/WTForms? None of the methods I’ve posted above seem right and I have no doubt this is a fairly common requirement.

Asked By: mal-wan

||

Answers:

The most elegant solution will no doubt require some javascript as you mentioned in your last idea. You can use JS to hide the different parts of your form and perform the necessary checks and/or data manipulations on the client side and ONLY when that is correct and complete submit it to your flask route.

I have used the first method you mentioned. Here is what it looked like:

@simple_blueprint.route('/give', methods=['GET', 'POST'])
@simple_blueprint.route('/give/step/<int:step>', methods=['GET', 'POST'])
@login_required
def give(step=0):
    form = GiveForm()
    ...
    return blah blah

You are right that this feels “hacky”. However it can work if the route doesn’t have to do much else besides handling the form. The way my route worked was to collect data and then ask users a bunch of questions about the data. The way you are explaining your situation, with the need to collect data on each step, I would really recommend the javascript solution.

Answered By: Dan Safee

I will try to simplify with general steps so that you can apply it to say shopping as easily as possible and to make the code more readable.

Code structure:

.
├── app.py
└── templates
    ├── finish.html
    └── step.html

Below I will provide the code for each of the files:

  • app.py
from flask import Flask, render_template, redirect, url_for, request, session
from flask_bootstrap import Bootstrap
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired
from flask_wtf import FlaskForm

app = Flask(__name__)
app.secret_key = 'secret'
bootstrap = Bootstrap(app)


class StepOneForm(FlaskForm):
    title = 'Step One'
    name = StringField('Name', validators=[InputRequired()])
    submit = SubmitField('Next')


class StepTwoForm(FlaskForm):
    title = 'Step Two'
    email = StringField('Email', validators=[InputRequired()])
    submit = SubmitField('Next')


class StepThreeForm(FlaskForm):
    title = 'Step Three'
    address = TextAreaField('Address', validators=[InputRequired()])
    submit = SubmitField('Next')


class StepFourForm(FlaskForm):
    title = 'Step Four'
    phone = StringField('Phone', validators=[InputRequired()])
    submit = SubmitField('Finish')


@app.route('/')
def index():
    return redirect(url_for('step', step=1))


@app.route('/step/<int:step>', methods=['GET', 'POST'])
def step(step):
    forms = {
        1: StepOneForm(),
        2: StepTwoForm(),
        3: StepThreeForm(),
        4: StepFourForm(),
    }

    form = forms.get(step, 1)

    if request.method == 'POST':
        if form.validate_on_submit():
            # Save form data to session
            session['step{}'.format(step)] = form.data
            if step < len(forms):
                # Redirect to next step
                return redirect(url_for('step', step=step+1))
            else:
                # Redirect to finish
                return redirect(url_for('finish'))

    # If form data for this step is already in the session, populate the form with it
    if 'step{}'.format(step) in session:
        form.process(data=session['step{}'.format(step)])

    content = {
        'progress': int(step / len(forms) * 100),
        'step': step, 
        'form': form,
    }
    return render_template('step.html', **content)


@app.route('/finish')
def finish():
    data = {}
    for key in session.keys():
        if key.startswith('step'):
            data.update(session[key])
    session.clear()
    return render_template('finish.html', data=data)


if __name__ == '__main__':
    app.run(debug=True)
  • finish.html
{% extends 'bootstrap/base.html' %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-md-8 offset-md-2">
      <h1>Finish</h1>
      <p>Thank you for your submission!</p>
      <table class="table">
        {% for key, value in data.items() %}
          {% if key not in ['csrf_token', 'submit', 'previous']%}
            <tr>
              <th>{{ key }}</th>
              <td>{{ value }}</td>
            </tr>
          {% endif %}
        {% endfor %}
      </table>
    </div>
  </div>
</div>
{% endblock %}
  • step.html
{% extends 'bootstrap/base.html' %}
{% import "bootstrap/wtf.html" as wtf %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-md-8 offset-md-2">
            <div class="progress mb-4">
                <div class="progress-bar" role="progressbar" style="width: {{ progress }}%" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100">{{ form.title }}: {{ progress }}%</div>
            </div>
            <br>
            {% with messages = get_flashed_messages() %}
            {% if messages %}
            <ul class=flashes>
              {% for message in messages %}
              <li>{{ message }}</li>
              {% endfor %}
            </ul>
            {% endif %}
            {% endwith %}
            <br>
            <h3>{{ form.title.upper() }}</h3>
            <hr>
            {{ wtf.quick_form(form) }}
            <br>
            {% if step > 1 %}
            <a href="{{ url_for('step', step=step-1) }}" class="btn btn-default">Previous</a>
            {% endif %}
        </div>
    </div>
</div>
{% endblock %}

OUTPUT:

enter image description here
enter image description here
enter image description here
enter image description here
enter image description here

Answered By: Milovan Tomašević