Flask扩展之Flask-WTF&WTForms
使用Flsak框架搭建web服务时,只要涉及到表单相关,我们通常会想起Flask-WTF&WTForms。这两者比较容易混淆,简单来说WTForms是一个支持多个web框架的form组件(包),主要用于对用户请求数据进行验证;而Flask-WTF这个扩展对独立的WTForms包进行了包装,方便集成到Flask应用中去。
一 Flask-WTF
上面已经提到,Flask-WTF是集成WTForms,同时,它带有csrf 令牌的安全表单和全局的csrf 保护的功能。每次我们在建立表单所创建的类都是继承与flask_wtf中的FlaskForm,而FlaskForm是继承WTForms中forms。
1.1 安装
pip3 isntall flask-wtf
1.2 配置
与其他多数扩展不同,Flask-WTF无须在应用层初始化,但是它要求应用配置一个密钥。密钥 是一个由随机字符构成的唯一字符串,通过加密或签名以不同的方式提升应用的安全性。Flask使用这个密钥保护用户会话,一方被篡改。每个应用的密钥不同,而且不能让任何人知道。下面示例如何在Flask应用中配置密钥。
from flask import Flask app = Flask(__name__) app.config['SECRET_KEY'] = 'hard to guess string'
Flask-WTF之所以要求应用配置一个密钥,是为了防止表单遭到跨站请求伪造(CSRF)攻击。恶意网站把请求发送到被攻击者已登录的其他网站时,就会引发CSRF攻击。Flask-WTF为所有表单生成安全令牌,存储在用户会话中。令牌是一种加密签名,根据密钥生成。
app.config字典用于存储Flask、扩展和应用自身的配置变量。更多配置操作请参考:Flask基础中第三章节:配置文件
提醒:为了增强安全性,密钥不应该直接写入源码,而是要保存在环境变量中。
1.3 创建表单类
使用Flask-WTF时,在服务器端,每个web表单都由一个继承自FlaskForm的类表示。这个类定义表单中的一组字段,每个字段都用对象表示。字段对象可附属一个或多个验证函数。验证函数用于验证用户提交的数据是否有效。
from flask_wtf import FlaskForm
from wtforms import StringField,SubmitField
from wtforms.validators import DataRequired
class NameForm(FlaskForm):
name = StringField('What is your name?', validators=[DataRequired()])
submit = SubmitField('Submit')这个表单中的字段都定义为类变量,而各个类变量的值是相应字段类型的对象。在这个示例中,NameForm表单中有一个名为name的文本字段和一个名为submit的提交按钮。StringField类表示属性为type=“text”的HTML<input>元素;SubmitField类表示属性为type=“submit”的HTML<input>元素。字段构造函数的第一个参数是把表单渲染成HTML时使用的标注(label)。
StringField构造函数中的可选参数validators指定一个由验证函数组成的列表,在接受用户提交的数据之前验证数据。
说明:FlaskForm基类由Flask-WTF扩展定义,所以要从flask_wtf中导入。然而,字段和验证函数却是直接从WTForms包中导入。
表1:WTForms支持的HTML标准字段
| 字段类型 | 说明 |
| StringField | 文本字段, 相当于type类型为text的input标签 |
| TextAreaField | 多行文本字段 |
| PasswordField | 密码文本字段 |
| HiddenField | 隐藏文本字段 |
| DateField | 文本字段,值为datetime.date格式 |
| DateTimeField | 文本字段,值为datetime.datetime格式 |
| IntegerField | 文本字段,值为整数 |
| DecimalField | 文本字段,值为decimal.Decimal |
| FloatField | 文本字段,值为浮点数 |
| BooleanField | 复选框,值为True 和 False |
| RadioField | 一组单选框 |
| SelectField | 下拉列表 |
| SelectMultipleField | 下拉列表,可选择多个值 |
| FileField | 文件上传字段 |
| SubmitField | 表单提交按钮 |
| FormFiled | 把表单作为字段嵌入另一个表单 |
| FieldList | 子组指定类型的字段 |
表2:WTForms验证函数
| 验证函数 | 说明 |
| | 验证是电子邮件地址 |
| EqualTo | 比较两个字段的值; 常用于要求输入两次密钥进行确认的情况 |
| IPAddress | 验证IPv4网络地址 |
| Length | 验证输入字符串的长度 |
| NumberRange | 验证输入的值在数字范围内 |
| Optional | 无输入值时跳过其它验证函数 |
| DataRequired | 确保字段中有数据 |
| Regexp | 使用正则表达式验证输入值 |
| URL | 验证url |
| AnyOf | 确保输入值在可选值列表中 |
| NoneOf | 确保输入值不在可选列表中 |
1.4 渲染
表单字段是可调用的,在模板中调用后会渲染成HTML。假设视图函数通过form参数把一个NameForm实例传入模板,在模板中可以生成一个简单的HTML表单,如下所示:
<form action="" method="post">
{{ form.hidden_tag() }}
{{ form.name.label }} {{ form.name() }}
{{ form.submit() }}
</form>注意,除了name和submit字段,这个表单还有个form.hidden_tag()元素。这个元素生成一个隐藏的字段,供Flask-WTF的CSRF防护机制使用。
我们还可以采用另外一种方式生成csrf_token:
<form method="post">
{{ form.csrf_token }}
</form>当然,这种方式渲染出来的表单还很简陋。调用字段时传入的任何关键字参数都将转换成字段的HTML属性。例如:可以为字段指定id或class属性,然后为其定义CSS样式:
<form action="" method="post">
{{ form.hidden_tag() }}
{{ form.name.label }} {{ form.name(id='xxx') }}
{{ form.submit() }}
</form>即便能指定HTML属性,这种方式渲染及美化表单的工作量还是很大,这时我们可以使用Flask-Bootstrap,Flask-Bootstrap扩展提供了一个高层级的辅助函数,可以使用Bootstrap预定义的表单样式渲染整个Flask-WTF表单。使用方式如下:
{% import "bootstrap/wtf.html" as wtf %}
{{ wtf.quick_form(form) }}
import指令的使用方法和普通Python代码一样,通过它可以导入模板元素,在多个模板中使用。导入的bootstrap/wtf.html文件中定义了一个使用Bootstrap渲染Flask-WTF表单对象的辅助函数。wtf.quick_form()函数的参数为Flask-WTF表单对象,使用Bootstrap的默认样式渲染传入的表单。
一个前端示例:index.html
{% import "bootstrap/wtf.html" as wtf %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Hello,{% if name %}{{ name }}{% else %} Stranger{% endif %}</h1>
{{ wtf.quick_form(form) }}
</body>
</html>1.5 在视图函数中处理表单
在视图函数中有两个任务:一是渲染表单;二是接收用户在表单中填写的数据。示例视图函数:
@main.route('/',methods=['GET','POST']) # methods参数告诉Flask,在URL映射中把该视图函数注册为GET和POST请求的处理函数。
def index():
name = None
form = NameForm()
if form.validate_on_submit(): # 提交表单后,如果数据能被所有的验证函数接受,那么validate_on_submit()方法返回值为True
name = form.name.data
form.name.data = ''
return render_template("index.html",form=form,name=name)补充说明--重定向
上面的代码存在一个可用性的问题。用户输入名字后提交表单,然后点击浏览器的刷新按钮,会看到一个莫名的警告(IE浏览器出现,谷歌浏览器未出现),要求在再次提交表单 之前进行确认。之所以会出现这种情况,是因为刷新页面时浏览器会重新发送之前发送过的请求。如果前一个请求是包含表单数据的POST请求,刷新页面后会再次提交表单。多数情况下,这并不是我们想执行的操作,因此浏览器才要求用户确认。
有什么解决办法呢?
那就是最好别让Web应用把POST请求作为浏览器发送的最后一个请求。
这种需求的实现方式是,使用重定向作为POST请求的响应,而不是使用常规响应。重定向是一种特殊的响应,响应内容包含URL,而不是HTML代码的字符串。浏览器收到这种响应时,会向重定向的URL发起GET请求,显示页面的内容。这个页面的加载可能要多花几毫秒,因为要先把第二个请求发送给服务器。除此之前,用户不会觉察到有什么不同。现在,前一个请求是GET请求,所以刷新命令能像预期的那样正常运作。这个技巧称为Post/重定向/Get模式。
但这种方法又会引起另外一个问题。应用处理POST请求时,可以通过form.name.data获取用户输入的名字,然而这个请求一旦结束,数据就不见了。因为POST请求使用重定向处理,所以要保存输入的名字,这样重定向后的请求才能获取并使用这个名字,从而构建真正的响应。
怎样保存呢?答案是把数据保存在用户会话中。修改代码如下:
@main.route('/',methods=['GET','POST'])
def index():
form = NameForm()
if form.validate_on_submit():
session['name'] = form.name.data
return redirect(url_for('index')) # 如果引入了蓝本,应该为蓝本名.index
return render_template("index.html",form=form,name=session.get('name'))1.6 自定义字段验证函数
class NameForm(FlaskForm):
name = StringField('What is your name?', validators=[DataRequired()])
submit = SubmitField('Submit')
def validate_name(self,field): # 表单中定义了以validate_开头且后面跟着字段名的方法,这个方法和常规的验证函数一起调用
if User.query.filter_by(name=field.data).first(): # 假设可以从数据库中查数据
raise validationError('User already in use.') # 想要表示验证失败,可以抛出validationError异常,其参数就是错误信息一个完整的用户注册表单示例:
from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo from wtforms import ValidationError from ..models import User # 数据库模型 class RegistrationForm(FlaskForm): email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()]) nickname = StringField('Nickname', validators=[ DataRequired(), Length(1, 64), # 确保user字段以字母开头,而且只包含字母、数字、下划线和点号。两个参数分别为正则表达式的标志和验证失败时显示的错误信息 Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, 'Usernames must have only letters, numbers, dots or underscores')]) password = PasswordField('Password', validators=[ DataRequired(), EqualTo('password2', message='Passwords must match.')]) # 判断两次密码是否一致,使用EqualTo()函数 password2 = PasswordField('Confirm password', validators=[DataRequired()]) submit = SubmitField('Register') # 验证函数,以validate_开头且后面跟着字段名的方法,该方法和常规的验证函数一起调用 def validate_email(self, field): if User.query.filter_by(email=field.data.lower()).first(): raise ValidationError('Email already registered.') def validate_nickname(self, field): if User.query.filter_by(nickname=field.data).first(): raise ValidationError('Nickname already in use.')
1.7 文件上传
Flask-WTF 提供 FileField 来处理文件上传,它在表单提交后,自动从 flask.request.files 中抽取数据。FileField的 data 属性是一个 Werkzeug FileStorage 实例。
from flask import Flask, render_template from flask_wtf import FlaskForm from wtforms import FileField,SubmitField from wtforms.validators import DataRequired class AvatarForm(FlaskForm): avatar = FileField('Avatar:', validators=[DataRequired()]) submit = SubmitField('Submit') @app.route('/avatar', methods=['GET', 'POST']) def avatar(): form = AvatarForm() if form.validate_on_submit(): # 方式一: avatar = request.files['avatar'] file_name = avatar.filename # 方式二:借助secure_filename # from werkzeug.utils import secure_filename # file_name = secure_filename(form.avatar.data.filename) UPLOAD_FOLDER = '...' # 指定保存地址 ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif'] # 指定文件格式范围 allowed_flag = '.' in file_name and file_name.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS #判断文件格式是否符合要求 if not allowed_flag: flash('Incorrect file type.') return redirect(url_for('avatar')) avatar.save(UPLOAD_FOLDER+file_name) # form.avatar.data.save(UPLOAD_FOLDER + file_name) return render_template("avatar.html", form=form)
二 WTForms
WTForms支持的HTML标准字段及WTForms验证函数参考第一节表1、表2。
2.1 安装
pip3 install wtforms
2.2 使用
2.2.1 创建表单类
from wtforms import Form # 要继承的类
from wtforms.fields import simple, html5, core # 这里面包含了生成的DOM, 比如input, redio, select等
from wtforms import validators, widgets # 校验器, 插件
class LoginForm(Form):
username = simple.StringField(
label = "用户名",
widget = widgets.TextInput(),
validators = [
# Myvalidators(message="用户名不能为空"), # 可以自定义正则
validators.Length(max=10, min=4, message="用户名长度必须大于等于%(min)d且小于等于%(max)d")
# my_length_check
],
default = 'joe1991',
render_kw = {"class":"form-control"} # 设置属性
)
pwd = simple.PasswordField(
label = "密码",
validators = [
validators.Length(max=10, min=4, message="用户名长度必须大于等于%(min)d且小于等于%(max)d"),
validators.Regexp(regex="\d+",message="密码必须是数字"),
],
widget = widgets.PasswordInput(),
render_kw = {"class":"form-control"}
)
参数解析:
# message = "" 数据不合格的提示信息
# default = (,) 默认值, 可以是一个或多个
# validators = [] 校验规则
# render_kw = {} 给标签添加属性, 比如class, style等
# widgets = widgets.PasswordInput() 定义使用的插件 需要注意的:
- 字段名区分大小写
- 字段名不能以'_'开头
- 字段名不能以'validate'开头
2.2.2 在视图中使用form类生成表单
@main.route('/login',methods=["GET","POST"])
def login():
if request.method =="GET":
form = LoginForm() # 实例表单对象
return render_template("login.html",form=form) # 传进模板
else:
form = LoginForm(formdata=request.form) # 将数据传到form表单对象进行校验
if form.validate(): # 判断校验的结果
print("用户提交的数据用过格式验证,值为:%s"%form.data)
return "登录成功"
else:
return render_template("login.html",form=form) # 当字段校验不合格时, 每一字段中就会有对象的error提示2.2.3 在模板中渲染表单
<h1>用户注册</h1>
{# 传进来的是form类对象, 循环这个对象就可以依次取出你所有定义的所有的字段(类属性) #}
<form method="post" novalidate>
{% for item in form %}
<p>{{ item.label }}: {{ item }} {{ item.errors[0] }}</p>
{# label: 填写项的提示信息, 如用户名#}
{# item: 定义的标签, 如input标签#}
{# item.errors[0] : 校验不合格的错误信息, 一般一个字段会有多个错误信息, 只要显示其中一个就好,本身是一个列表 #}
{% endfor %}
<input type="submit" value="提交">
</form>2.3 自定义Validators验证器
第一种: in-line validator(内联验证器)
自定义一个验证函数,在定义表单类的时候,在对应的字段中加入该函数进行认证。下面的my_length_check函数就是用于判name字段长度不能超过50。
def my_length_check(form, field):
if len(field.data) < 4 or len(field.data) > 10:
raise validators.ValidationError('用户名长度必须大于等于4且小于等于10')
class LoginForm(Form):
username = simple.StringField(
label="用户名",
validators=[
validators.DataRequired(message="用户名不能为空"),
# validators.Length(max=10, min=4, message="用户名长度必须大于等于%(min)d且小于等于%(max)d")
my_length_check
],
)第二种:通用且可重用的验证函数
一般是以validate开头,加上下划线再加上对应的field字段(validate_filed),浏览器在提交表单数据时,会自动识别对应字段所有的验证器,然后执行验证器进行判断。
此类方式与第一节1.6自定义字段验证函数一致,只是继承类由FlaskForm改为Form。
第三种:比较高级的validators
class Myvalidators(object):
def __init__(self, message):
self.message = message
def __call__(self, form, field):
if len(field.data) != 0:
return None
raise validators.ValidationError(self.message)
class LoginForm(Form):
username = simple.StringField(
label="用户名",
validators=[
Myvalidators(message="用户名不能为空"), # 可以自定义正则
],
)2.4 meta
from flask import Flask, render_template, request, redirect, session from wtforms import Form from wtforms.csrf.core import CSRF from wtforms.fields import simple,html5,core from wtforms import validators,widgets from hashlib import md5 app = Flask(__name__, template_folder='templates') app.debug = True class MyCSRF(CSRF): """ Generate a CSRF token based on the user's IP. I am probably not very secure, so don't use me. """ def setup_form(self, form): self.csrf_context = form.meta.csrf_context() self.csrf_secret = form.meta.csrf_secret return super(MyCSRF, self).setup_form(form) def generate_csrf_token(self, csrf_token): gid = self.csrf_secret + self.csrf_context token = md5(gid.encode('utf-8')).hexdigest() return token def validate_csrf_token(self, form, field): print(field.data, field.current_token) if field.data != field.current_token: raise ValueError('Invalid CSRF') class TestForm(Form): name = html5.EmailField(label='用户名') pwd = simple.StringField(label='密码') class Meta: # -- CSRF # 是否自动生成CSRF标签 csrf = True # 生成CSRF标签name csrf_field_name = 'csrf_token' # 自动生成标签的值,加密用的csrf_secret csrf_secret = 'xxxxxx' # 自动生成标签的值,加密用的csrf_context csrf_context = lambda x: request.url # 生成和比较csrf标签 csrf_class = MyCSRF # -- i18n # 是否支持本地化 # locales = False locales = ('zh', 'en') # 是否对本地化进行缓存 cache_translations = True # 保存本地化缓存信息的字段 translations_cache = {} @app.route('/index', methods=['GET', 'POST']) def index(): if request.method == 'GET': form = TestForm() else: form = TestForm(formdata=request.form) if form.validate(): print(form) return render_template('index.html', form=form) if __name__ == '__main__': app.run()
2.5 一个完整的用户注册实例
from flask import Flask,render_template,redirect,request,url_for from wtforms import Form from wtforms.fields import simple,html5,core from wtforms import validators,widgets app = Flask(__name__, template_folder="templates") app.debug = True class RegisterForm(Form): # =============================simple============================= username = simple.StringField( label = "用户名", validators = [ validators.DataRequired(message = "用户名不能为空!") ], # widget = widgets.TextInput(), default = "joe1991" # 设置默认值 ) pwd = simple.PasswordField( label = "密码", validators = [ validators.DataRequired(message = "密码不能为空!") ] ) pwd_confim = simple.PasswordField( label = "确认密码", validators = [ validators.DataRequired(message = "重复密码不能为空!"), validators.EqualTo("pwd",message = "两次密码不一致!") ], widget = widgets.PasswordInput(), render_kw = {"style": "width:200px;"} ) # =============================html5============================= email = html5.EmailField( # 注意这里用的是html5.EmailField label = "邮箱", validators = [ validators.DataRequired(message = "邮箱不能为空!"), validators.Email(message = "邮箱格式错误!") ], widget = widgets.TextInput(input_type = "email"), render_kw={"style": "width:200px;"} ) # =============================core============================= gender = core.RadioField( label = "性别", choices = ( (1,"男"), (2,"女"), ), coerce = int # 限制是int类型的 ) city = core.SelectField( label = "城市", choices = ( ("beijing","北京"), ("shanghai","上海"), ) ) hobby = core.SelectMultipleField( label = "爱好", choices=( (1, '篮球'), (2, '足球'), (3, '台球'), (4, '乒乓球'), ), widget = widgets.ListWidget(prefix_label = False), option_widget = widgets.CheckboxInput(), coerce = int, default = [1, 2] ) def __init__(self,*args,**kwargs): # 这里的self是一个RegisterForm对象 '''重写__init__方法''' super(RegisterForm,self).__init__(*args, **kwargs) # 继承父类的__init__方法 self.hobby.choices =((1, '篮球'), (2, '足球'), (3, '台球'), (4, '羽毛球')) # 把RegisterForm这个类里面的hobby重新赋值 def validate_pwd_confim(self,field): ''' 自定义pwd_config字段规则,例:与pwd字段是否一致 :param field: :return: ''' # 最开始初始化时,self.data中已经有所有的值 if field.data != self.data['pwd']: # raise validators.ValidationError("密码不一致!") # 继续后续验证 raise validators.StopValidation("密码不一致!") # 不再继续后续验证 @app.route('/register',methods=["GET","POST"]) def register(): if request.method=="GET": form = RegisterForm(data={'gender': 1}) # 默认是1 else: form = RegisterForm(formdata = request.form) if form.validate(): # 判断是否验证成功 print('用户提交数据通过格式验证,提交的值为:', form.data) # 所有的正确信息 return redirect(url_for('.register')) else: print(form.errors) # 所有的错误信息 return render_template('register.html', form=form) if __name__ == '__main__': app.run()
参考:

浙公网安备 33010602011771号