Flask Forms Handling with Flask-WTF

Module 23: Web Frameworks II (Python) - Tuesday, Lecture 1

Introduction to Web Forms

Before we dive into Flask-WTF, let's understand the role of forms in web applications:

Forms are the primary way users interact with web applications, enabling activities like user registration, content creation, search, and more. A well-designed form system should:

graph TD A[User] -->|Submits Form| B[Web Server] B -->|Validates Input| C{Valid?} C -->|Yes| D[Process Data] C -->|No| E[Return Form with Errors] D --> F[Database/Storage] D --> G[Return Success Response] E --> A G --> A

Handling forms without a framework is cumbersome and requires a lot of repetitive code:

@app.route('/register', methods=['GET', 'POST'])
def register_without_framework():
    errors = {}
    # Default values
    username = ''
    email = ''
    
    if request.method == 'POST':
        # Get form data
        username = request.form.get('username', '')
        email = request.form.get('email', '')
        password = request.form.get('password', '')
        confirm = request.form.get('confirm', '')
        
        # Validate data
        if not username:
            errors['username'] = 'Username is required'
        elif len(username) < 3:
            errors['username'] = 'Username must be at least 3 characters'
        
        if not email:
            errors['email'] = 'Email is required'
        elif '@' not in email:
            errors['email'] = 'Invalid email format'
        
        if not password:
            errors['password'] = 'Password is required'
        elif len(password) < 8:
            errors['password'] = 'Password must be at least 8 characters'
        
        if password != confirm:
            errors['confirm'] = 'Passwords do not match'
        
        # If no errors, process the registration
        if not errors:
            # Create user, save to database, etc.
            flash('Registration successful!')
            return redirect(url_for('login'))
    
    # Render template with form
    return render_template('register.html', 
                          username=username,
                          email=email,
                          errors=errors)

This approach is tedious, error-prone, and hard to maintain. Flask-WTF provides a more elegant solution.

What is Flask-WTF?

Flask-WTF is an extension that simplifies form handling in Flask by integrating WTForms, a flexible form validation and rendering library for Python.

Key features of Flask-WTF include:

The Restaurant Order Form Analogy

Think of Flask-WTF as a sophisticated restaurant order system. Instead of waiters having to manually check each order for validity (e.g., "Did the customer specify how they want their steak cooked?", "Is this menu item actually available?"), the system validates orders automatically, provides standardized forms that ensure all necessary information is collected, and protects against errors like double-ordering or ordering from the wrong menu.

Setting Up Flask-WTF

To get started with Flask-WTF, you need to install it and configure it in your Flask application:

Installation

pip install Flask-WTF

Basic Configuration

from flask import Flask
from flask_wtf.csrf import CSRFProtect

app = Flask(__name__)
# Secret key for form security
app.config['SECRET_KEY'] = 'your-secret-key-here'  # Use a strong, random key in production!

# Optional: Enable CSRF protection globally
csrf = CSRFProtect(app)

# Or, if you prefer to enable CSRF only on specific views:
# csrf = CSRFProtect()
# csrf.init_app(app)

The SECRET_KEY is crucial for security—it's used to cryptographically sign cookies and generate CSRF tokens. In production, use a strong, random key and keep it confidential.

Real-World Security Tip

Never hard-code your secret key in your code, especially if it's stored in version control. Instead, use environment variables or a configuration file that's excluded from version control. This is a standard security practice to prevent exposure of sensitive keys.

# Using environment variables
import os
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY')

# Or using a configuration file
import json
with open('/path/to/config.json', 'r') as f:
    config = json.load(f)
app.config['SECRET_KEY'] = config['secret_key']

Creating Your First Form

With Flask-WTF, you define forms as Python classes that inherit from FlaskForm:

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo

class RegistrationForm(FlaskForm):
    username = StringField('Username', 
                         validators=[DataRequired(), Length(min=3, max=20)])
    email = StringField('Email', 
                       validators=[DataRequired(), Email()])
    password = PasswordField('Password', 
                           validators=[DataRequired(), Length(min=8)])
    confirm_password = PasswordField('Confirm Password', 
                                    validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Sign Up')

This class defines a registration form with four fields, each with appropriate validators:

Using Forms in Views

Here's how to use the form in a Flask view function:

from flask import Flask, render_template, flash, redirect, url_for
from forms import RegistrationForm

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

@app.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    
    if form.validate_on_submit():
        # Form has been submitted and is valid
        # Process the data
        username = form.username.data
        email = form.email.data
        password = form.password.data
        
        # Create user account, save to database, etc.
        flash(f'Account created for {username}!', 'success')
        return redirect(url_for('login'))
    
    # Either the request is GET or the form validation failed
    return render_template('register.html', form=form)

The validate_on_submit() method checks if:

  1. The request method is POST (i.e., the form was submitted)
  2. All validators pass (the form data is valid)

If both conditions are met, the form is considered valid, and you can process the data. Otherwise, the form is re-rendered with validation error messages.

flowchart TD A[View Function] --> B{Request Method?} B -->|GET| C[Render Empty Form] B -->|POST| D[Validate Form] D --> E{Valid?} E -->|Yes| F[Process Data] E -->|No| G[Render Form with Errors] F --> H[Redirect] C --> I[Response to Browser] G --> I H --> I

Rendering Forms in Templates

Flask-WTF makes no assumptions about how you render forms in templates, giving you complete flexibility. Here are different approaches:

Manual Rendering

<form method="POST" action="{{ url_for('register') }}">
    {{ form.hidden_tag() }}
    
    <div class="form-group">
        {{ form.username.label }}
        {{ form.username(class="form-control") }}
        {% if form.username.errors %}
            <div class="text-danger">
                {% for error in form.username.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>
    
    <div class="form-group">
        {{ form.email.label }}
        {{ form.email(class="form-control") }}
        {% if form.email.errors %}
            <div class="text-danger">
                {% for error in form.email.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>
    
    <div class="form-group">
        {{ form.password.label }}
        {{ form.password(class="form-control") }}
        {% if form.password.errors %}
            <div class="text-danger">
                {% for error in form.password.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>
    
    <div class="form-group">
        {{ form.confirm_password.label }}
        {{ form.confirm_password(class="form-control") }}
        {% if form.confirm_password.errors %}
            <div class="text-danger">
                {% for error in form.confirm_password.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>
    
    {{ form.submit(class="btn btn-primary") }}
</form>

The form.hidden_tag() function is crucial—it includes any hidden fields, particularly the CSRF token that protects against cross-site request forgery attacks.

Using Form Field Rendering with Macros

To avoid repetition, you can create Jinja2 macros for form fields:

{% macro render_field(field) %}
    <div class="form-group">
        {{ field.label }}
        {{ field(class="form-control") }}
        {% if field.errors %}
            <div class="text-danger">
                {% for error in field.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>
{% endmacro %}

<form method="POST" action="{{ url_for('register') }}">
    {{ form.hidden_tag() }}
    {{ render_field(form.username) }}
    {{ render_field(form.email) }}
    {{ render_field(form.password) }}
    {{ render_field(form.confirm_password) }}
    {{ form.submit(class="btn btn-primary") }}
</form>

Bootstrap Integration Example

If you're using Bootstrap for your UI, you might want to integrate your forms with Bootstrap styles:

{% macro render_bootstrap_field(field) %}
    <div class="mb-3">
        {{ field.label(class="form-label") }}
        
        {% if field.type == 'BooleanField' %}
            <div class="form-check">
                {{ field(class="form-check-input") }}
            </div>
        {% elif field.type == 'RadioField' %}
            <div class="form-check">
                {{ field(class="form-check-input") }}
            </div>
        {% elif field.type == 'FileField' %}
            {{ field(class="form-control-file") }}
        {% else %}
            {{ field(class="form-control") }}
        {% endif %}
        
        {% if field.errors %}
            <div class="invalid-feedback d-block">
                {% for error in field.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
        
        {% if field.description %}
            <small class="form-text text-muted">{{ field.description }}</small>
        {% endif %}
    </div>
{% endmacro %}

Common Form Field Types

WTForms provides a wide variety of field types to handle different types of data:

Field Type HTML Equivalent Common Use Cases
StringField <input type="text"> Names, titles, short text
TextAreaField <textarea> Long text, descriptions
PasswordField <input type="password"> Passwords, sensitive data
BooleanField <input type="checkbox"> Yes/no options, agreements
DateField <input type="date"> Birth dates, appointment dates
EmailField <input type="email"> Email addresses
FileField <input type="file"> File uploads
IntegerField <input type="number"> Numeric values without decimals
FloatField <input type="number" step="any"> Decimal numbers
SelectField <select> Dropdown lists, option selection
RadioField <input type="radio"> Single-choice from multiple options
MultipleFileField <input type="file" multiple> Multiple file uploads

Example with Multiple Field Types

from flask_wtf import FlaskForm
from wtforms import (StringField, TextAreaField, BooleanField, SelectField,
                    DateField, IntegerField, DecimalField, SubmitField)
from wtforms.validators import DataRequired, NumberRange, Length

class ProductForm(FlaskForm):
    name = StringField('Product Name', validators=[DataRequired(), Length(max=100)])
    description = TextAreaField('Description', validators=[Length(max=500)])
    price = DecimalField('Price ($)', places=2, validators=[DataRequired(), NumberRange(min=0)])
    quantity = IntegerField('Quantity in Stock', validators=[DataRequired(), NumberRange(min=0)])
    category = SelectField('Category', choices=[
        ('electronics', 'Electronics'),
        ('clothing', 'Clothing & Apparel'),
        ('home', 'Home & Kitchen'),
        ('books', 'Books & Media'),
        ('other', 'Other')
    ])
    release_date = DateField('Release Date', format='%Y-%m-%d')
    featured = BooleanField('Featured Product')
    submit = SubmitField('Save Product')

Form Validation

WTForms provides a rich set of validators to ensure data integrity:

Common Validators

Validator Purpose Example
DataRequired Field cannot be empty DataRequired(message='This field is required')
Email Must be a valid email address Email(message='Invalid email address')
Length Enforces text length Length(min=8, max=50)
NumberRange Numeric value within range NumberRange(min=18, max=100)
URL Must be a valid URL URL(message='Invalid URL')
EqualTo Field must match another field EqualTo('password', message='Passwords must match')
Regexp Field must match pattern Regexp(r'^[A-Za-z]+$', message='Letters only')
Optional Field is optional but validated if provided Optional()
InputRequired Input is required (vs. data) InputRequired(message='Input is required')

Custom Validators

You can create custom validators for specific requirements:

from wtforms.validators import ValidationError

# As a function
def validate_username(form, field):
    if field.data.lower() in ['admin', 'superuser', 'administrator']:
        raise ValidationError('This username is reserved')

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[
        DataRequired(), 
        Length(min=3, max=20),
        validate_username  # Apply the custom validator
    ])
    
# As a class
class ProhibitedWords:
    def __init__(self, prohibited_words, message=None):
        self.prohibited_words = prohibited_words
        self.message = message or 'This field contains prohibited words'
        
    def __call__(self, form, field):
        for word in self.prohibited_words:
            if word.lower() in field.data.lower():
                raise ValidationError(self.message)

class CommentForm(FlaskForm):
    content = TextAreaField('Comment', validators=[
        DataRequired(),
        ProhibitedWords(['spam', 'advertise', 'free trial'], 
                       'No promotional content allowed')
    ])

Field-Level Validation

You can also add custom validation at the field level by overriding validate_<fieldname> methods:

class UserForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    email = StringField('Email', validators=[DataRequired(), Email()])
    
    def validate_username(self, username):
        user = User.query.filter_by(username=username.data).first()
        if user:
            raise ValidationError('That username is already taken')
    
    def validate_email(self, email):
        user = User.query.filter_by(email=email.data).first()
        if user:
            raise ValidationError('That email is already registered')

File Uploads with Flask-WTF

Flask-WTF simplifies file uploads with form validation:

from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import SubmitField

class PhotoUploadForm(FlaskForm):
    photo = FileField('Upload Photo', validators=[
        FileRequired(),
        FileAllowed(['jpg', 'png', 'gif'], 'Images only!')
    ])
    submit = SubmitField('Upload')

To process the uploaded file in your view:

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    form = PhotoUploadForm()
    
    if form.validate_on_submit():
        filename = secure_filename(form.photo.data.filename)
        form.photo.data.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
        flash('Photo uploaded successfully!', 'success')
        return redirect(url_for('index'))
        
    return render_template('upload.html', form=form)

Real-World Security Considerations for File Uploads

File uploads present significant security risks. Here are some essential precautions:

  • Always validate file types and extensions using FileAllowed
  • Use secure_filename() from Werkzeug to sanitize filenames
  • Store uploaded files outside the web root if possible
  • Consider using cloud storage services for production
  • Set maximum file size limits
  • Scan uploads for malware if handling sensitive systems

Advanced Form Features

Dynamic Form Fields

Sometimes you need to create forms with a variable number of fields or dynamically generated choices:

class DynamicForm(FlaskForm):
    # The field will be created dynamically
    
    def __init__(self, *args, categories=None, **kwargs):
        super(DynamicForm, self).__init__(*args, **kwargs)
        
        # Create a dynamic category field based on database values
        if categories is not None:
            self.category = SelectField(
                'Category',
                choices=[(c.id, c.name) for c in categories],
                coerce=int
            )
            
# In your view
@app.route('/create-post', methods=['GET', 'POST'])
def create_post():
    # Get categories from database
    categories = Category.query.all()
    
    # Create form with dynamic categories
    form = DynamicForm(categories=categories)
    
    if form.validate_on_submit():
        # Process form
        pass
        
    return render_template('create_post.html', form=form)

Form Inheritance

You can use inheritance to create related forms that share common fields:

class BaseUserForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(min=3, max=20)])
    email = StringField('Email', validators=[DataRequired(), Email()])

class RegistrationForm(BaseUserForm):
    password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
    confirm_password = PasswordField('Confirm Password', 
                                    validators=[DataRequired(), EqualTo('password')])
    accept_tos = BooleanField('I accept the Terms of Service', validators=[DataRequired()])
    submit = SubmitField('Register')

class ProfileUpdateForm(BaseUserForm):
    bio = TextAreaField('Bio', validators=[Length(max=200)])
    website = StringField('Website', validators=[Optional(), URL()])
    submit = SubmitField('Update Profile')

Multi-Step Forms

For complex registration or workflows, you might want to split forms across multiple steps:

from flask import session

# Step 1: Personal Details
@app.route('/register/step1', methods=['GET', 'POST'])
def register_step1():
    form = PersonalDetailsForm()
    
    if form.validate_on_submit():
        # Store form data in session
        session['registration_data'] = {
            'first_name': form.first_name.data,
            'last_name': form.last_name.data,
            'email': form.email.data
        }
        return redirect(url_for('register_step2'))
    
    return render_template('register_step1.html', form=form)

# Step 2: Account Details
@app.route('/register/step2', methods=['GET', 'POST'])
def register_step2():
    # Ensure step 1 was completed
    if 'registration_data' not in session:
        return redirect(url_for('register_step1'))
    
    form = AccountDetailsForm()
    
    if form.validate_on_submit():
        # Get data from previous step
        registration_data = session['registration_data']
        
        # Add new data
        registration_data.update({
            'username': form.username.data,
            'password': form.password.data
        })
        
        # Create user account with all the data
        create_user(registration_data)
        
        # Clear session data
        session.pop('registration_data')
        
        flash('Registration successful!', 'success')
        return redirect(url_for('login'))
    
    return render_template('register_step2.html', form=form)

Real-World Example: Contact Form with reCAPTCHA

Let's implement a more comprehensive example: a contact form with reCAPTCHA protection against spam.

Form Definition

from flask_wtf import FlaskForm, RecaptchaField
from wtforms import StringField, TextAreaField, SelectField, SubmitField
from wtforms.validators import DataRequired, Email, Length

class ContactForm(FlaskForm):
    name = StringField('Your Name', validators=[
        DataRequired(),
        Length(min=2, max=50)
    ])
    
    email = StringField('Your Email', validators=[
        DataRequired(),
        Email(message='Please enter a valid email address')
    ])
    
    subject = SelectField('Subject', choices=[
        ('general', 'General Inquiry'),
        ('support', 'Technical Support'),
        ('billing', 'Billing Question'),
        ('feedback', 'Feedback'),
        ('other', 'Other')
    ])
    
    message = TextAreaField('Message', validators=[
        DataRequired(),
        Length(min=10, max=1000, message='Your message must be between 10 and 1000 characters')
    ])
    
    # Add reCAPTCHA field
    recaptcha = RecaptchaField()
    
    submit = SubmitField('Send Message')

Configuration Setup

# In app.py or config.py
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'development-key')
app.config['RECAPTCHA_PUBLIC_KEY'] = os.environ.get('RECAPTCHA_PUBLIC_KEY')
app.config['RECAPTCHA_PRIVATE_KEY'] = os.environ.get('RECAPTCHA_PRIVATE_KEY')

# Optional configurations
app.config['RECAPTCHA_OPTIONS'] = {'theme': 'light'}

View Function

@app.route('/contact', methods=['GET', 'POST'])
def contact():
    form = ContactForm()
    
    if form.validate_on_submit():
        # Create a record of the contact submission
        contact_submission = ContactSubmission(
            name=form.name.data,
            email=form.email.data,
            subject=form.subject.data,
            message=form.message.data,
            ip_address=request.remote_addr,
            submitted_at=datetime.utcnow()
        )
        db.session.add(contact_submission)
        
        # Send email notification
        send_contact_email(form.email.data, form.name.data, 
                          form.subject.data, form.message.data)
        
        # Commit database changes
        db.session.commit()
        
        flash('Your message has been sent! We will respond shortly.', 'success')
        return redirect(url_for('contact_thank_you'))
    
    return render_template('contact.html', form=form, 
                         title='Contact Us')

Template Implementation

{% extends "base.html" %}

{% macro render_field(field) %}
    <div class="form-group {% if field.errors %}has-error{% endif %}">
        {{ field.label(class="form-label") }}
        
        {% if field.type == 'RecaptchaField' %}
            {{ field }}
        {% else %}
            {{ field(class="form-control") }}
        {% endif %}
        
        {% if field.errors %}
            <div class="invalid-feedback d-block">
                {% for error in field.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
        
        {% if field.description %}
            <small class="form-text text-muted">{{ field.description }}</small>
        {% endif %}
    </div>
{% endmacro %}

{% block content %}
    <div class="container py-5">
        <div class="row">
            <div class="col-md-8 offset-md-2">
                <div class="card shadow">
                    <div class="card-header bg-primary text-white">
                        <h3 class="mb-0">Contact Us</h3>
                    </div>
                    
                    <div class="card-body">
                        <p class="lead">We'd love to hear from you! Fill out the form below to get in touch.</p>
                        
                        <form method="POST" action="{{ url_for('contact') }}">
                            {{ form.hidden_tag() }}
                            
                            <div class="row">
                                <div class="col-md-6">
                                    {{ render_field(form.name) }}
                                </div>
                                <div class="col-md-6">
                                    {{ render_field(form.email) }}
                                </div>
                            </div>
                            
                            {{ render_field(form.subject) }}
                            {{ render_field(form.message) }}
                            {{ render_field(form.recaptcha) }}
                            
                            <div class="text-center mt-4">
                                {{ form.submit(class="btn btn-primary btn-lg") }}
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endblock %}

Practice Activity

Create a registration form for a fictional online learning platform with the following requirements:

  1. Basic user information:
    • Username (3-20 characters, required)
    • Email (valid format, required)
    • Password (minimum 8 characters, required)
    • Confirm Password (must match password)
  2. Profile information:
    • Full Name (required)
    • Age (must be at least 16)
    • Education Level (dropdown with options like High School, Bachelor's, Master's, etc.)
    • Areas of Interest (checkboxes for multiple selections)
  3. Additional features:
    • Profile picture upload (only allow image files)
    • Terms of Service agreement checkbox (required)
    • Newsletter subscription option
  4. Custom validation:
    • Check if username is already taken (you can simulate this)
    • Password strength requirements (at least one uppercase, one lowercase, one digit)

Implement this form with Flask-WTF and create templates to render it with appropriate error handling and styling.

Further Topics to Explore