Skip to main content

Overview

The application uses Flask-WTF and WTForms for form handling and validation, providing:

CSRF Protection

Automatic protection against cross-site request forgery

Validation

Server-side validation with built-in validators

Error Handling

Automatic error messages and field-level errors

Basic Form Structure

Creating a Form Class

Forms are defined in forms.py within each module:
app/catalogs/colors/forms.py
"""
Forms for the colors module.
"""

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Length


class ColorForm(FlaskForm):
    """Form for creating a color."""

    name = StringField(
        'Name',
        validators=[
            DataRequired(message='Color name is required'),
            Length(max=50, message='Name cannot exceed 50 characters'),
        ],
    )

Form with Multiple Fields

app/catalogs/wood_types/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Length


class WoodTypeForm(FlaskForm):
    """Form for creating a wood type."""

    name = StringField(
        'Name',
        validators=[
            DataRequired(message='Wood type name is required'),
            Length(min=3, max=50, message='Name must be between 3 and 50 characters'),
        ],
    )
    
    description = StringField(
        'Description',
        validators=[
            Length(max=200, message='Description cannot exceed 200 characters'),
        ],
    )

Common Field Types

Available Fields

from wtforms import (
    StringField,      # Text input
    TextAreaField,    # Multi-line text
    IntegerField,     # Integer numbers
    FloatField,       # Decimal numbers
    BooleanField,     # Checkboxes
    DateField,        # Date picker
    DateTimeField,    # Date and time
    SelectField,      # Dropdown select
    RadioField,       # Radio buttons
    SubmitField,      # Submit button
)

Field Examples

class ProductForm(FlaskForm):
    # Text input
    name = StringField('Product Name', validators=[DataRequired()])
    
    # Multi-line text
    description = TextAreaField('Description', validators=[Length(max=500)])
    
    # Number input
    quantity = IntegerField('Quantity', validators=[DataRequired()])
    price = FloatField('Price', validators=[DataRequired()])
    
    # Checkbox
    is_active = BooleanField('Active')
    
    # Dropdown
    category = SelectField(
        'Category',
        choices=[('furniture', 'Furniture'), ('material', 'Material')],
        validators=[DataRequired()]
    )

Validators

Built-in Validators

from wtforms.validators import (
    DataRequired,     # Field must not be empty
    Length,           # String length constraints
    Email,            # Valid email format
    EqualTo,          # Must equal another field
    NumberRange,      # Number within range
    Regexp,           # Match regex pattern
    URL,              # Valid URL format
    Optional,         # Field is optional
)

Validator Examples

class UserForm(FlaskForm):
    # Required field
    username = StringField(
        'Username',
        validators=[DataRequired(message='Username is required')]
    )
    
    # Length constraints
    name = StringField(
        'Name',
        validators=[
            DataRequired(),
            Length(min=3, max=50, message='Name must be 3-50 characters')
        ]
    )
    
    # Email validation
    email = StringField(
        'Email',
        validators=[DataRequired(), Email(message='Invalid email address')]
    )
    
    # Number range
    age = IntegerField(
        'Age',
        validators=[NumberRange(min=18, max=100, message='Age must be 18-100')]
    )
    
    # Password confirmation
    password = PasswordField('Password', validators=[DataRequired()])
    confirm_password = PasswordField(
        'Confirm Password',
        validators=[DataRequired(), EqualTo('password', message='Passwords must match')]
    )
    
    # Optional field
    phone = StringField(
        'Phone',
        validators=[Optional(), Length(max=15)]
    )

Using Forms in Routes

Create Form (GET/POST)

app/catalogs/colors/routes.py
from flask import flash, redirect, render_template, url_for

from . import colors_bp
from .forms import ColorForm
from .services import ColorService
from app.exceptions import ConflictError


@colors_bp.route('/create', methods=['GET', 'POST'])
def create_color():
    """
    Display form and create a new color.
    
    GET: Render the creation form.
    POST: Validate form, create color, and redirect.
    """
    form = ColorForm()

    if form.validate_on_submit():
        data = {'name': form.name.data}
        try:
            ColorService.create(data)
            flash('Color created successfully', 'success')
            return redirect(url_for('colors.create_color'))
        except ConflictError as e:
            flash(e.message, 'error')

    return render_template('colors/create.html', form=form)

Edit Form with Pre-population

app/catalogs/colors/routes.py
from flask import flash, redirect, render_template, request, url_for

@colors_bp.route('/<int:id_color>/edit', methods=['GET', 'POST'])
def edit_color(id_color: int):
    """
    Display pre-populated form and update a color.
    
    GET: Render form with current data.
    POST: Validate form, update color, and redirect.
    """
    try:
        color = ColorService.get_by_id(id_color)
    except NotFoundError as e:
        flash(e.message, 'error')
        return redirect(url_for('colors.list_colors'))

    form = ColorForm()

    if form.validate_on_submit():
        data = {'name': form.name.data}
        try:
            ColorService.update(id_color, data)
            flash('Color updated successfully', 'success')
            return redirect(url_for('colors.list_colors'))
        except (ConflictError, ValidationError) as e:
            flash(e.message, 'error')
    elif request.method == 'GET':
        # Pre-populate form on GET request
        form.name.data = color.name

    return render_template('colors/edit.html', form=form, color=color)

Form Validation Flow

1

Instantiate Form

Create form instance from request data:
form = ColorForm()
2

Validate on Submit

Check if form was submitted and is valid:
if form.validate_on_submit():
    # Form is valid, process data
3

Access Field Data

Retrieve validated data from form fields:
data = {'name': form.name.data}
4

Handle Errors

Catch service exceptions and flash messages:
except ConflictError as e:
    flash(e.message, 'error')
5

Redirect on Success

Use PRG pattern after successful submission:
return redirect(url_for('colors.list_colors'))

Rendering Forms in Templates

Basic Form Rendering

app/templates/colors/create.html
{% extends "base.html" %}

{% block content %}
<h1>Add New Color</h1>

<form method="POST" action="{{ url_for('colors.create_color') }}">
  {# CSRF protection - REQUIRED #}
  {{ form.hidden_tag() }}
  
  {# Field label #}
  {{ form.name.label }}
  
  {# Field input #}
  {{ form.name(size=30) }}
  
  {# Display validation errors #}
  {% for error in form.name.errors %}
    <p style="color: red;">{{ error }}</p>
  {% endfor %}
  
  <button type="submit">Create</button>
</form>
{% endblock %}

Field with Attributes

{# Add HTML attributes to fields #}
{{ form.name(size=30, class="form-control", placeholder="Enter color name") }}

{# Checkbox #}
{{ form.is_active(checked=true) }}

{# TextArea #}
{{ form.description(rows=5, cols=40) }}

Complete Form Example

<form method="POST" action="{{ url_for('colors.create_color') }}">
  {{ form.hidden_tag() }}
  
  <div class="form-group">
    {{ form.name.label }}
    {{ form.name(class="form-control") }}
    {% if form.name.errors %}
      {% for error in form.name.errors %}
        <p class="error">{{ error }}</p>
      {% endfor %}
    {% endif %}
  </div>
  
  <div class="form-group">
    {{ form.description.label }}
    {{ form.description(class="form-control", rows=4) }}
    {% if form.description.errors %}
      {% for error in form.description.errors %}
        <p class="error">{{ error }}</p>
      {% endfor %}
    {% endif %}
  </div>
  
  <button type="submit" class="btn btn-primary">Submit</button>
</form>

CSRF Protection

Why CSRF Protection?

CSRF (Cross-Site Request Forgery) protection prevents malicious sites from submitting forms on behalf of your users.

Including CSRF Token

Always include {{ form.hidden_tag() }} in every form. Forms will fail validation without it.
<form method="POST">
  {# This includes the CSRF token #}
  {{ form.hidden_tag() }}
  
  <!-- Rest of form -->
</form>

CSRF for Inline Forms

For simple delete/action forms without WTForms:
<form method="POST" action="{{ url_for('colors.delete_color', id_color=color.id_color) }}">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
  <button type="submit">Delete</button>
</form>

Custom Validators

Creating Custom Validators

from wtforms.validators import ValidationError

def validate_hex_color(form, field):
    """
    Custom validator for hex color codes.
    """
    value = field.data
    if value and not value.startswith('#'):
        raise ValidationError('Hex code must start with #')
    if value and len(value) != 7:
        raise ValidationError('Hex code must be 7 characters')


class ColorForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    hex_code = StringField(
        'Hex Code',
        validators=[Optional(), validate_hex_color]
    )

Inline Field Validation

class ColorForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    
    def validate_name(self, field):
        """
        Custom validation method for name field.
        Method name must be validate_<field_name>.
        """
        if field.data and field.data.lower() in ['none', 'null']:
            raise ValidationError('Invalid color name')

Form Best Practices

Inherit from FlaskForm for automatic CSRF protection:
from flask_wtf import FlaskForm

class MyForm(FlaskForm):
    pass
Always provide user-friendly error messages:
name = StringField(
    'Name',
    validators=[
        DataRequired(message='Name is required'),
        Length(max=50, message='Name cannot exceed 50 characters')
    ]
)
Check both POST method and validation:
if form.validate_on_submit():
    # Form was submitted via POST and is valid
Retrieve validated field values:
name = form.name.data
Always redirect after successful POST:
if form.validate_on_submit():
    # Process form
    flash('Success!', 'success')
    return redirect(url_for('view_name'))

Error Handling

Field-Level Errors

Display errors for specific fields:
{% for error in form.name.errors %}
  <p style="color: red;">{{ error }}</p>
{% endfor %}

Form-Level Errors

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 %}

Service Layer Errors

Handle business logic errors with flash messages:
try:
    ColorService.create(data)
    flash('Color created successfully', 'success')
    return redirect(url_for('colors.list_colors'))
except ConflictError as e:
    flash(e.message, 'error')
except ValidationError as e:
    flash(e.message, 'error')

Real-World Example: ColorForm

Complete example from the application:
app/catalogs/colors/forms.py
"""
Forms for the colors module.
"""

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Length


class ColorForm(FlaskForm):
    """Form for creating/editing a color."""

    name = StringField(
        "Name",
        validators=[
            DataRequired(message="Color name is required"),
            Length(max=50, message="Name cannot exceed 50 characters"),
        ],
    )
Usage in route:
app/catalogs/colors/routes.py
@colors_bp.route("/create", methods=["GET", "POST"])
def create_color():
    form = ColorForm()

    if form.validate_on_submit():
        data = {"name": form.name.data}
        try:
            ColorService.create(data)
            flash("Color created successfully", "success")
            return redirect(url_for("colors.create_color"))
        except ConflictError as e:
            flash(e.message, "error")

    return render_template("colors/create.html", form=form)
Template rendering:
app/templates/colors/create.html
{% extends "base.html" %}

{% block content %}
<h1>Add New Color</h1>

<form method="POST" action="{{ url_for('colors.create_color') }}">
    {{ form.hidden_tag() }}

    {{ form.name.label }}
    {{ form.name(size=30) }}
    {% for error in form.name.errors %}
        <p style="color: red;">{{ error }}</p>
    {% endfor %}

    <button type="submit">Create</button>
</form>
{% endblock %}

Next Steps

Templates Guide

Learn Jinja2 template patterns

Coding Conventions

Follow coding standards

Project Structure

Understand the architecture

Deployment

Deploy to production

Build docs developers (and LLMs) love