第 4 章 · WTForms 表单与 CSRF
本章目标:使用 Flask-WTF 定义 ProductForm 并完成商品新建/编辑页面;掌握字段校验、ValidationError 与自定义 validate_* 钩子;在模板中正确渲染字段并展示错误信息;理解 CSRFProtect 与隐藏字段 csrf_token 的原理;完成 GET 展示 → POST 校验 → 重定向的完整流程。
学时建议:4~5 小时(含 2 小时跟练)
前置:完成 flask-web ch03(模板继承、flash、admin 蓝图列表页)。
4.1 场景说明:运营录入商品
shop-demo 用 Django ModelForm 绑定 Product 模型;api-demo 在 ch05 接入数据库前,先用 WTForms 定义表单类,字段与未来将建的 Product 模型对齐。
| 场景 | 字段 | 校验规则 |
|---|---|---|
| 新建商品 | name, slug, price, stock, category_id | 必填、价格 > 0 |
| 编辑商品 | 同上 | slug 唯一(ch05 查库) |
| 安全 | 所有 POST | CSRF Token |
对外 API https://api.example.com 使用 Token 鉴权(ch07);管理页使用 Session + CSRF 防御跨站伪造。
4.2 安装 Flask-WTF
pip install "flask-wtf>=1.2,<2"
pip freeze >> requirements.txt
| 包 | 作用 |
|---|---|
flask-wtf | Flask 集成 WTForms |
WTForms | 字段定义与校验(依赖) |
api_demo/extensions.py 更新:
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
api_demo/__init__.py 注册:
from api_demo.extensions import csrf
def create_app(config_name=None):
# ...
csrf.init_app(app)
# ...
api_demo/config.py 确认:
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-change-in-prod")
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None
4.3 定义 ProductForm
api_demo/forms/product.py:
from flask_wtf import FlaskForm
from wtforms import DecimalField, IntegerField, SelectField, StringField
from wtforms.validators import DataRequired, NumberRange, Length, ValidationError
class ProductForm(FlaskForm):
name = StringField(
"商品名称",
validators=[DataRequired(message="请输入名称"), Length(max=120)],
)
slug = StringField(
"URL 标识 (slug)",
validators=[DataRequired(), Length(max=64)],
)
price = DecimalField(
"售价",
places=2,
validators=[DataRequired(), NumberRange(min=0.01, message="价格须大于 0")],
)
stock = IntegerField(
"库存",
validators=[DataRequired(), NumberRange(min=0, message="库存不能为负")],
)
category_id = SelectField(
"分类",
coerce=int,
choices=[],
validators=[DataRequired()],
)
def validate_slug(self, field):
blocked = {"duplicate-slug"}
if field.data in blocked:
raise ValidationError("该 slug 已被占用")
| 字段类型 | 对应 HTML | 典型校验器 |
|---|---|---|
StringField | <input type="text"> | DataRequired, Length |
DecimalField | text + 解析 | NumberRange |
IntegerField | number | NumberRange |
SelectField | <select> | DataRequired |
FlaskForm 子类自动带 CSRF 字段,模板中需渲染 form.hidden_tag()。
4.4 视图:创建与编辑
api_demo/routes/admin.py(表单相关片段):
from flask import Blueprint, flash, redirect, render_template, url_for
from api_demo.forms.product import ProductForm
bp = Blueprint("admin", __name__, url_prefix="/admin")
_PRODUCTS = {}
_NEXT_ID = 1
CATEGORY_CHOICES = [(1, "数码"), (2, "家居"), (3, "食品")]
def _fill_category_choices(form):
form.category_id.choices = CATEGORY_CHOICES
@bp.route("/products/create", methods=["GET", "POST"])
def product_create():
form = ProductForm()
_fill_category_choices(form)
if form.validate_on_submit():
global _NEXT_ID
pid = _NEXT_ID
_NEXT_ID += 1
_PRODUCTS[pid] = {
"id": pid,
"name": form.name.data,
"slug": form.slug.data,
"price": float(form.price.data),
"stock": form.stock.data,
"category_id": form.category_id.data,
}
flash(f"商品「{form.name.data}」创建成功", "success")
return redirect(url_for("admin.product_list"))
return render_template("admin/product_form.html", form=form, title="新建商品")
@bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
def product_edit(product_id):
product = _PRODUCTS.get(product_id)
if not product:
flash("商品不存在", "error")
return redirect(url_for("admin.product_list"))
form = ProductForm(data=product)
_fill_category_choices(form)
if form.validate_on_submit():
product.update({
"name": form.name.data,
"slug": form.slug.data,
"price": float(form.price.data),
"stock": form.stock.data,
"category_id": form.category_id.data,
})
flash("保存成功", "success")
return redirect(url_for("admin.product_list"))
return render_template("admin/product_form.html", form=form, title="编辑商品")
| 方法 | 行为 |
|---|---|
| GET | 展示空表单或预填数据 |
| POST | validate_on_submit() = 校验通过且为 POST |
| 成功 | flash + redirect(PRG 模式) |
| 失败 | 重新渲染模板,字段旁显示错误 |