返回文章列表

WTForms Flask 表單建立與進階應用

本文介紹如何使用 WTForms 在 Flask 應用程式中建立和管理 Web 表單,包含欄位驗證、自定義欄位、檔案上傳和 CSRF 保護等進階技巧。文章涵蓋了表單類別的定義、驗證器的使用、自定義欄位和元件的建立,以及檔案上傳的處理和 CSRF 攻擊的防範,提供開發者更全面的表單處理方案。

Web 開發 Python

WTForms 簡化了 Flask 應用程式中 Web 表單的建立和管理,提供便捷的資料處理和驗證機制。開發者可以定義表單類別,並使用內建或自定義的驗證器確保資料有效性。文章也探討了自定義欄位和元件的建立,例如根據資料函式庫動態生成下拉選單選項,以及客製化前端顯示方式。此外,文章詳細說明瞭檔案上傳的完整流程,包含設定儲存路徑、安全檔名處理以及資料函式庫整合。最後,文章強調了 CSRF 保護的重要性,並提供如何在表單和 AJAX 請求中加入 CSRF Token 的方法,確保應用程式安全性。

使用 WTForms 建立 Web 表單

本章節將介紹如何使用 WTForms 套件在 Flask 應用程式中建立和管理 Web 表單。WTForms 提供了一個簡單且有效的方式來處理表單資料和驗證。

建立產品表單

首先,我們需要定義一個表單類別來處理產品的建立。在 models.py 中,我們定義了一個 ProductForm 類別:

from decimal import Decimal
from flask_wtf import FlaskForm
from wtforms import StringField, DecimalField, SelectField
from wtforms.validators import InputRequired, NumberRange

class ProductForm(FlaskForm):
    name = StringField('名稱', validators=[InputRequired()])
    price = DecimalField('價格', validators=[InputRequired(), NumberRange(min=Decimal('0.0'))])
    category = SelectField('類別', validators=[InputRequired()], coerce=int)

這個表單類別包含了三個欄位:namepricecategory。每個欄位都有對應的驗證器,以確保輸入的資料是有效的。

內容解密:

  1. StringField 用於輸入字串型別的資料,例如產品名稱。
  2. DecimalField 用於輸入小數型別的資料,例如產品價格。NumberRange 驗證器確保價格不小於 0。
  3. SelectField 用於選擇類別,coerce=int 表示選擇的值將被轉換為整數。

處理表單提交

views.py 中,我們定義了一個 create_product 方法來處理表單提交:

@catalog.route('/product-create', methods=['GET', 'POST'])
def create_product():
    form = ProductForm(meta={'csrf': False})
    categories = [(c.id, c.name) for c in Category.query.all()]
    form.category.choices = categories
    if form.validate_on_submit():
        name = form.name.data
        price = form.price.data
        category = Category.query.get_or_404(form.category.data)
        product = Product(name, price, category)
        db.session.add(product)
        db.session.commit()
        flash('產品 %s 已建立' % name, 'success')
        return redirect(url_for('catalog.product', id=product.id))
    if form.errors:
        flash(form.errors, 'danger')
    return render_template('product-create.html', form=form)

內容解密:

  1. form.validate_on_submit() 用於檢查表單是否被提交且資料是否有效。
  2. 如果資料有效,則建立一個新的產品例項並儲存到資料函式庫中。
  3. 如果資料無效,則顯示錯誤訊息。

驗證欄位

WTForms 提供了多種驗證器來檢查輸入的資料。在上面的例子中,我們使用了 InputRequiredNumberRange 驗證器。

建立共用表單

我們可以建立一個共用表單類別來包含共用的欄位。例如,我們可以建立一個 NameForm 類別:

class NameForm(FlaskForm):
    name = StringField('名稱', validators=[InputRequired()])

然後,其他表單類別可以繼承自這個共用表單類別:

class ProductForm(NameForm):
    price = DecimalField('價格', validators=[InputRequired(), NumberRange(min=Decimal('0.0'))])
    category = SelectField('類別', validators=[InputRequired()], coerce=int)

class CategoryForm(NameForm):
    pass

內容解密:

  1. NameForm 類別包含了一個 name 欄位。
  2. ProductFormCategoryForm 類別繼承自 NameForm,因此也包含了 name 欄位。

使用 WTForms 處理 Web 表單

自定義欄位與驗證

在前面的章節中,我們使用 SelectField 來處理產品的類別選擇。現在,我們將自定義一個欄位來自動從資料函式庫中擷取類別選項。

如何實作

首先,在 models.py 中定義一個自定義的 CategoryField 類別:

class CategoryField(SelectField):
    def iter_choices(self):
        categories = [(c.id, c.name) for c in Category.query.all()]
        for value, label in categories:
            yield (value, label, self.coerce(value) == self.data)

    def pre_validate(self, form):
        for v, _ in [(c.id, c.name) for c in Category.query.all()]:
            if self.data == v:
                break
        else:
            raise ValueError(self.gettext('Not a valid choice'))

class ProductForm(NameForm):
    price = DecimalField('Price', validators=[InputRequired(), NumberRange(min=Decimal('0.0'))])
    category = CategoryField('Category', validators=[InputRequired()], coerce=int)

程式碼解析

class CategoryField(SelectField):
    # ...
  • 我們定義了一個 CategoryField 類別,繼承自 SelectField
  • iter_choices 方法用於產生類別選項的迭代器,直接從資料函式庫中擷取類別資料。
def iter_choices(self):
    categories = [(c.id, c.name) for c in Category.query.all()]
    for value, label in categories:
        yield (value, label, self.coerce(value) == self.data)
  • pre_validate 方法用於驗證所選的類別是否有效。
def pre_validate(self, form):
    for v, _ in [(c.id, c.name) for c in Category.query.all()]:
        if self.data == v:
            break
    else:
        raise ValueError(self.gettext('Not a valid choice'))

修改 views.py

由於我們已經在 CategoryField 中處理了類別選項的擷取,因此我們可以從 create_product 方法中移除以下兩行程式碼:

categories = [(c.id, c.name) for c in Category.query.all()]
form.category.choices = categories

自定義驗證

我們可以定義一個自定義的驗證器來檢查類別名稱是否重複。

def check_duplicate_category(case_sensitive=True):
    def _check_duplicate(form, field):
        if case_sensitive:
            res = Category.query.filter(Category.name.like('%' + field.data + '%')).first()
        else:
            res = Category.query.filter(Category.name.ilike('%' + field.data + '%')).first()
        if res:
            raise ValidationError('Category named %s already exists' % field.data)
    return _check_duplicate

class CategoryForm(NameForm):
    name = StringField('Name', validators=[InputRequired(), check_duplicate_category()])

程式碼解析

def check_duplicate_category(case_sensitive=True):
    # ...
  • 我們定義了一個 check_duplicate_category 函式,用於建立一個驗證器。
  • 驗證器會檢查資料函式庫中是否存在相同的類別名稱。

建立自定義元件

我們可以建立自定義的元件來控制欄位在前端的顯示方式。

如何實作

首先,在 models.py 中定義一個自定義的 CustomCategoryInput 類別:

from wtforms.widgets import html_params, Select
from markupsafe import Markup

class CustomCategoryInput(Select):
    def __call__(self, field, **kwargs):
        kwargs.setdefault('id', field.id)
        html = []
        for val, label, selected in field.iter_choices():
            html.append('<input type="radio" %s> %s' % (html_params(name=field.name, value=val, checked=selected, **kwargs), label))
        return Markup(' '.join(html))

class CategoryField(SelectField):
    widget = CustomCategoryInput()
    # ...

程式碼解析

class CustomCategoryInput(Select):
    def __call__(self, field, **kwargs):
        # ...
  • 我們定義了一個 CustomCategoryInput 類別,繼承自 Select
  • __call__ 方法用於產生欄位的 HTML 程式碼。

上傳檔案

我們可以使用 Flask 和 WTForms 來處理檔案上傳。

如何實作

首先,在 my_app/__init__.py 中設定 UPLOAD_FOLDER

import os
ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'])
app.config['UPLOAD_FOLDER'] = os.path.realpath('.') + '/my_app/static/uploads'

程式碼解析

app.config['UPLOAD_FOLDER'] = os.path.realpath('.') + '/my_app/static/uploads'
  • 我們設定了 UPLOAD_FOLDER 的路徑,用於存放上傳的檔案。

使用 WTForms 處理 Web 表單

上傳檔案至表單

my_app/catalog/models.py 檔案中,新增以下程式碼:

from flask_wtf.file import FileField, FileRequired

class Product(db.Model):
    image_path = db.Column(db.String(255))

    def __init__(self, name, price, category, image_path):
        self.image_path = image_path

class ProductForm(NameForm):
    image = FileField('產品圖片', validators=[FileRequired()])

內容解密:

  • FileField 用於處理檔案上傳,FileRequired 驗證器確保使用者必須上傳檔案。
  • Product 模型新增 image_path 欄位以儲存上傳圖片的路徑。
  • ProductForm 新增 image 欄位,用於接收上傳的圖片。

接著,在 my_app/catalog/views.py 中修改 create_product 方法以儲存上傳的檔案:

import os
from werkzeug.utils import secure_filename
from my_app import ALLOWED_EXTENSIONS

@catalog.route('/product-create', methods=['GET', 'POST'])
def create_product():
    form = ProductForm(meta={'csrf': False})
    if form.validate_on_submit():
        name = form.name.data
        price = form.price.data
        category = Category.query.get_or_404(form.category.data)
        image = form.image.data
        if allowed_file(image.filename):
            filename = secure_filename(image.filename)
            image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
            product = Product(name, price, category, filename)
            db.session.add(product)
            db.session.commit()
            flash('產品 %s 已建立' % name, 'success')
            return redirect(url_for('catalog.product', id=product.id))
    if form.errors:
        flash(form.errors, 'danger')
    return render_template('product-create.html', form=form)

內容解密:

  • 使用 secure_filename 確保上傳檔案的檔名安全,避免安全漏洞。
  • 將上傳的圖片儲存至組態的 UPLOAD_FOLDER 目錄中。
  • 將圖片檔名儲存至資料函式庫中的 image_path 欄位。

templates/product-create.html 中新增圖片上傳欄位:

<form method="POST" action="{{ url_for('catalog.create_product') }}" role="form" enctype="multipart/form-data">
    <!-- 其他欄位定義 -->
    <div class="form-group">{{ form.image.label }}: {{ form.image(style='display:inline;') }}</div>
    <button type="submit" class="btn btn-default">提交</button>
</form>

內容解密:

  • enctype="multipart/form-data" 是必要的,以支援檔案上傳。
  • form.image 欄位用於上傳圖片。

templates/product.html 中顯示上傳的圖片:

<img src="{{ url_for('static', filename='uploads/' + product.image_path) }}"/>

內容解密:

  • 使用 url_for 生成圖片的 URL,假設圖片儲存在 static/uploads 目錄下。

防範 CSRF 攻擊

預設情況下,Flask-WTF 提供 CSRF 保護。只需移除 meta={'csrf': False} 即可啟用:

form = ProductForm()

並在應用組態中設定 CSRF 金鑰:

app.config['WTF_CSRF_SECRET_KEY'] = '隨機金鑰'

在表單中新增 CSRF Token 欄位:

<form method="POST" action="/some-action-like-create-product">
    {{ form.csrf_token }}
</form>

對於 AJAX 請求,可以透過以下方式新增 CSRF Token:

$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type)) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken)
        }
    }
})

內容解密:

  • CSRF Token 用於防止跨站請求偽造攻擊。
  • AJAX 請求需要在 Header 中加入 CSRF Token。