Forms
Flask-WTF integrates Flask with WTForms, providing form handling, validation, and CSRF protection.
Setup and Configuration
Installation
pip install Flask-WTF
Basic Configuration
from flask import Flask
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
# Enable CSRF protection
csrf = CSRFProtect(app)
Basic Form Creation
Simple Form
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class LoginForm(FlaskForm):
username = StringField(
'Username',
validators=[DataRequired(), Length(min=4, max=25)]
)
password = PasswordField(
'Password',
validators=[DataRequired()]
)
submit = SubmitField('Sign In')
Form Fields
from wtforms import (
StringField, TextAreaField, PasswordField,
IntegerField, FloatField, BooleanField,
SelectField, SelectMultipleField, RadioField,
DateField, DateTimeField, FileField,
HiddenField, SubmitField
)
class UserForm(FlaskForm):
# Text fields
username = StringField('Username')
bio = TextAreaField('Biography')
password = PasswordField('Password')
# Numeric fields
age = IntegerField('Age')
salary = FloatField('Salary')
# Boolean field
active = BooleanField('Active')
# Selection fields
country = SelectField('Country', choices=[
('us', 'United States'),
('uk', 'United Kingdom'),
('ca', 'Canada')
])
# Multiple selection
languages = SelectMultipleField('Languages', choices=[
('en', 'English'),
('es', 'Spanish'),
('fr', 'French')
])
# Radio buttons
gender = RadioField('Gender', choices=[
('m', 'Male'),
('f', 'Female'),
('o', 'Other')
])
# Date fields
birth_date = DateField('Birth Date')
created_at = DateTimeField('Created At')
# File upload
avatar = FileField('Avatar')
# Hidden field
user_id = HiddenField('User ID')
# Submit button
submit = SubmitField('Save')
Validators
Built-in Validators
from wtforms.validators import (
DataRequired, Optional, Length, NumberRange,
Email, EqualTo, Regexp, URL, AnyOf, NoneOf,
InputRequired, ValidationError
)
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(),
Length(min=4, max=25),
Regexp('^[A-Za-z][A-Za-z0-9_.]*$', message='Invalid username')
])
email = StringField('Email', validators=[
DataRequired(),
Email(message='Invalid email address')
])
password = PasswordField('Password', validators=[
DataRequired(),
Length(min=8, message='Password must be at least 8 characters')
])
confirm_password = PasswordField('Confirm Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
age = IntegerField('Age', validators=[
NumberRange(min=13, max=120, message='Age must be between 13 and 120')
])
website = StringField('Website', validators=[
Optional(),
URL(message='Invalid URL')
])
role = SelectField('Role', validators=[
AnyOf(['admin', 'user', 'moderator'])
])
Custom Validators
def validate_username(form, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already exists')
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(),
validate_username # Custom validator
])
# Or as a method
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('Email already registered')
Form Handling in Views
Basic Form Handling
from flask import render_template, flash, redirect, url_for
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
# Process form data
user = User.query.filter_by(username=username).first()
if user and check_password(user.password_hash, password):
login_user(user)
flash('Logged in successfully!', 'success')
return redirect(url_for('dashboard'))
else:
flash('Invalid username or password', 'error')
return render_template('login.html', form=form)
Form with File Upload
from werkzeug.utils import secure_filename
import os
@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
form = UploadForm()
if form.validate_on_submit():
file = form.file.data
if file:
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
flash('File uploaded successfully!', 'success')
return redirect(url_for('upload_file'))
return render_template('upload.html', form=form)
Pre-populate Form
@app.route('/edit-user/<int:user_id>', methods=['GET', 'POST'])
def edit_user(user_id):
user = User.query.get_or_404(user_id)
form = UserForm(obj=user) # Pre-populate with user data
if form.validate_on_submit():
form.populate_obj(user) # Update user object
db.session.commit()
flash('User updated successfully!', 'success')
return redirect(url_for('user_list'))
return render_template('edit_user.html', form=form, user=user)
Template Rendering
Basic Form Rendering
<!-- login.html -->
<form method="POST">
{{ form.hidden_tag() }}
<div>
{{ form.username.label }} {{ form.username(class="form-control") }} {% for
error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.password.label }} {{ form.password(class="form-control") }} {% for
error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
Macro for Form Rendering
<!-- macros.html -->
{% macro render_field(field) %}
<div class="form-group">
{{ field.label(class="form-label") }} {{ field(class="form-control") }} {% if
field.errors %}
<div class="invalid-feedback">
{% for error in field.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
<!-- Using the macro -->
{% from 'macros.html' import render_field %}
<form method="POST">
{{ form.hidden_tag() }} {{ render_field(form.username) }} {{
render_field(form.password) }} {{ form.submit(class="btn btn-primary") }}
</form>
Bootstrap Form Styling
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }} {{
form.username(class="form-control") }} {% if form.username.errors %}
<div class="invalid-feedback d-block">
{% for error in form.username.errors %} {{ error }} {% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<div class="form-check">
{{ form.remember_me(class="form-check-input") }} {{
form.remember_me.label(class="form-check-label") }}
</div>
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
CSRF Protection
Automatic CSRF Protection
<!-- CSRF token automatically included -->
<form method="POST">
{{ form.hidden_tag() }}
<!-- Form fields -->
</form>
Manual CSRF Token
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<!-- Form fields -->
</form>
CSRF Configuration
from flask_wtf.csrf import CSRFProtect, CSRFError
app.config['WTF_CSRF_TIME_LIMIT'] = None # No time limit
app.config['WTF_CSRF_SECRET_KEY'] = 'csrf-secret-key'
csrf = CSRFProtect(app)
@app.errorhandler(CSRFError)
def handle_csrf_error(e):
return render_template('csrf_error.html', reason=e.description), 400
Dynamic Forms
Form with Dynamic Fields
class DynamicForm(FlaskForm):
def __init__(self, choices=None, *args, **kwargs):
super(DynamicForm, self).__init__(*args, **kwargs)
if choices:
self.category.choices = choices
name = StringField('Name', validators=[DataRequired()])
category = SelectField('Category', coerce=int)
submit = SubmitField('Save')
# In view
@app.route('/dynamic', methods=['GET', 'POST'])
def dynamic():
categories = [(c.id, c.name) for c in Category.query.all()]
form = DynamicForm(choices=categories)
if form.validate_on_submit():
# Process form
pass
return render_template('dynamic.html', form=form)
FieldList for Multiple Items
from wtforms import FieldList, FormField
class ItemForm(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
quantity = IntegerField('Quantity', validators=[DataRequired()])
class OrderForm(FlaskForm):
customer = StringField('Customer', validators=[DataRequired()])
items = FieldList(FormField(ItemForm), min_entries=1)
submit = SubmitField('Place Order')
# In template
{% for item_form in form.items %}
<div class="item">
{{ item_form.name.label }} {{ item_form.name() }}
{{ item_form.quantity.label }} {{ item_form.quantity() }}
</div>
{% endfor %}
File Upload
File Upload Form
from flask_wtf.file import FileField, FileRequired, FileAllowed
from werkzeug.utils import secure_filename
class UploadForm(FlaskForm):
file = FileField('File', validators=[
FileRequired(),
FileAllowed(['jpg', 'png', 'gif'], 'Images only!')
])
submit = SubmitField('Upload')
# Configuration
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
app.config['UPLOAD_FOLDER'] = 'uploads'
Multiple File Upload
from wtforms import MultipleFileField
class MultipleUploadForm(FlaskForm):
files = MultipleFileField('Files')
submit = SubmitField('Upload')
# In view
@app.route('/upload-multiple', methods=['GET', 'POST'])
def upload_multiple():
form = MultipleUploadForm()
if form.validate_on_submit():
for file in form.files.data:
if file:
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
flash('Files uploaded successfully!', 'success')
return redirect(url_for('upload_multiple'))
return render_template('upload_multiple.html', form=form)
Form Validation
Server-side Validation
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# Form is valid
user = User(
username=form.username.data,
email=form.email.data
)
db.session.add(user)
db.session.commit()
return redirect(url_for('login'))
# Form has errors
return render_template('register.html', form=form)
Client-side Validation
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('registration-form');
form.addEventListener('submit', function (e) {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (password !== confirmPassword) {
e.preventDefault();
alert('Passwords do not match');
}
});
});
</script>
Advanced Features
Custom Field Types
from wtforms import Field
from wtforms.widgets import TextInput
class TagListField(Field):
widget = TextInput()
def _value(self):
if self.data:
return ', '.join(self.data)
else:
return ''
def process_formdata(self, valuelist):
if valuelist:
self.data = [x.strip() for x in valuelist[0].split(',')]
else:
self.data = []
class ArticleForm(FlaskForm):
title = StringField('Title')
tags = TagListField('Tags')
submit = SubmitField('Save')
Conditional Validation
class ConditionalForm(FlaskForm):
user_type = SelectField('User Type', choices=[
('individual', 'Individual'),
('company', 'Company')
])
first_name = StringField('First Name')
last_name = StringField('Last Name')
company_name = StringField('Company Name')
def validate_first_name(self, field):
if self.user_type.data == 'individual' and not field.data:
raise ValidationError('First name is required for individuals')
def validate_company_name(self, field):
if self.user_type.data == 'company' and not field.data:
raise ValidationError('Company name is required for companies')
Form Inheritance
class BaseForm(FlaskForm):
created_at = DateTimeField('Created At', default=datetime.utcnow)
updated_at = DateTimeField('Updated At', default=datetime.utcnow)
class UserForm(BaseForm):
username = StringField('Username', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
class PostForm(BaseForm):
title = StringField('Title', validators=[DataRequired()])
content = TextAreaField('Content', validators=[DataRequired()])
Error Handling
Form Error Display
<!-- Display all form errors -->
{% if form.errors %}
<div class="alert alert-danger">
<ul>
{% for field, errors in form.errors.items() %} {% for error in errors %}
<li>{{ field }}: {{ error }}</li>
{% endfor %} {% endfor %}
</ul>
</div>
{% endif %}
Custom Error Messages
class LoginForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(message='Please enter your username')
])
password = PasswordField('Password', validators=[
DataRequired(message='Please enter your password'),
Length(min=8, message='Password must be at least 8 characters long')
])
Testing Forms
Form Testing
def test_login_form():
form_data = {
'username': 'testuser',
'password': 'testpass',
'csrf_token': 'test_token'
}
form = LoginForm(data=form_data)
assert form.validate() == True
def test_invalid_form():
form_data = {
'username': '', # Invalid: empty username
'password': 'testpass'
}
form = LoginForm(data=form_data)
assert form.validate() == False
assert 'This field is required.' in form.username.errors
Integration Testing
def test_form_submission(client):
response = client.post('/login', data={
'username': 'testuser',
'password': 'testpass'
}, follow_redirects=True)
assert response.status_code == 200
assert b'Welcome' in response.data