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是继承WTFormsforms

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类型为textinput标签

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验证函数

验证函数

说明

Email

验证是电子邮件地址

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>

注意,除了namesubmit字段,这个表单还有个form.hidden_tag()元素。这个元素生成一个隐藏的字段,供Flask-WTFCSRF防护机制使用。

我们还可以采用另外一种方式生成csrf_token

<form method="post">
    {{ form.csrf_token }}
</form>

当然,这种方式渲染出来的表单还很简陋。调用字段时传入的任何关键字参数都将转换成字段的HTML属性。例如:可以为字段指定idclass属性,然后为其定义CSS样式:

<form action="" method="post">
    {{ form.hidden_tag() }}
    {{ form.name.label }} {{ form.name(id='xxx') }}
    {{ form.submit() }}
</form>

即便能指定HTML属性,这种方式渲染及美化表单的工作量还是很大,这时我们可以使用Flask-BootstrapFlask-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>
View Code

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.')
View Code

1.7 文件上传

Flask-WTF 提供 FileField 来处理文件上传,它在表单提交后,自动从 flask.request.files 中抽取数据。FileFielddata 属性是一个 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)
View Code

二 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()     定义使用的插件	

需要注意的:

  1. 字段名区分大小写
  2. 字段名不能以'_'开头
  3. 字段名不能以'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()
View Code

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()
View Code


参考:

https://wtforms.readthedocs.io/en/stable/

https://flask-wtf.readthedocs.io/en/stable/

posted @ 2019-09-09 19:22  Joe1991  阅读(376)  评论(0)    收藏  举报