基础篇 第四章 Flask表单
前言
在Web程序中,表单是和用户交互最常见的方式之一。用户注册、登录、撰写文章、编辑设置,无一不用到表单。不过,表单的处理却并不简单。你不仅要创建表单,验证用户输入的内容,向用户显示错误提示,还要获取并保存数据。幸运的是,强大的WTForms可以帮我们解这些问题。WTForms是一个使用Python编写的表单库,它使得表单的定义、验证(服务器端)和处理变得非常轻松。这一章我们会介绍在Web程序中处理表单的方法和技巧。
4. Flask 表单
本章新涉及的Python包如下所示:
- WTForms(2.2)
·主页:https://github.com/wtforms/wtforms
·文档:https://wtforms.readthedocs.io/en/latest/ - Flask-WTF(0.14.2)
·主页:https://github.com/lepture/flask-wtf
·文档:https://flask-wtf.readthedocs.io/en/latest/ - Flask-CKEditor(0.4.0)
·主页:https://github.com/greyli/flask-ckeditor
·文档:https://flask-ckeditor.readthedocs.io/
4.1 HTML表单
在HTML中,表单通过<form>
标签创建,表单中的字段使用<input>
标签定义。下面是一个非常简单的HTML表单:
<form method="post">
<label for="username">Username</label><br>
<input type="text" name="username" placeholder="Héctor Rivera"><br>
<label for="password">Password</label><br>
<input type="password" name="password" placeholder="19001130"><br>
<input id="remember" name="remember" type="checkbox" checked>
<label for="remember"><small>Remember me</small></label><br>
<input type="submit" name="submit" value="Log in">
</form>
在HTML表单中,我们创建<input>
标签表示各种输入字段,<label>
标签则用来定义字段的标签文字。我们可以在<form>
和<input>
标签中使用各种属性来对表单进行设置。上面的表单被浏览器解析后会生成两个输入框,一个勾选框和一个提交按钮。如果你运行了示例程序,访问http://localhost:5000/html可以看到渲染后的表单:
关于HTML表单的具体定义和用法可以访问
https://www.w3.org/TR/html401/interact/forms.html查看.
WTForms支持在Python中使用类定义表单,然后直接通过类定义生成对应的HTML代码,这种方式更加方便,而且使表单更易于重用。因此,除非是非常简单的程序,或者是你想让表单的定义更加灵活,否则我们一般不会在模板中直接使用HTML编写表单。
4.2 使用Flask-WTF处理表单
扩展Flask-WTF集成了WTForms,使用它可以在Flask中更方便地使用WTForms。Flask-WTF将表单数据解析、CSRF保护、文件上传等功能与Flask集成,另外还附加了reCAPTCHA支持。
reCAPTCHA(https://www.google.com/recaptcha/)是Google开发的免费验证码服务,在国内目前无法直接使用。首先,和其他扩展一样,我们先用Pipenv安装Flask-WTF及其依赖:
pipenv install flask-wtf
Flask-WTF
默认为每个表单启用CSRF保护,它会为我们自动生成和验证CSRF令牌。默认情况下,Flask-WTF使用程序密钥来对CSRF令牌进行签名,所以我们需要为程序设置密钥:
app.secret_key = 'secret string'
4.2.1 定义WTForms表单类
当使用WTForms创建表单时,表单由Python类表示,这个类继承从WTForms导入的Form基类。一个表单由若干个输入字段组成,这些字段分别用表单类的类属性来表示(字段即Field,你可以简单理解为表单内的输入框、按钮等部件)。下面定义了一个LoginForm类,最终会生成我们在前面定义的HTML表单.
当使用Flask-WTF定义表单时,我们仍然使用WTForms提供的字段类和验证器,创建的方式也完全相同,只不过表单类要继承Flask-WTF提供的FlaskForm类。FlaskForm类继承自Form类,进行了一些设置,并附加了一些辅助方法,以便与Flask集成。因为本章的示例程序中包含多个表单类,为了便于组织,我们创建了一个forms.py
脚本,用来存储所有的表单类:
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
remember = BooleanField('Remember me')
submit = SubmitField('Log in')
每个字段属性通过实例化WTForms
提供的字段类表示。字段属性的名称将作为对应HTML<input>
元素的name
属性及id属性值.
这里的LoginForm表单类中定义了四个字段:文本字段StringField
、密码字段Password-Field
、勾选框字段BooleanField
和提交按钮字段SubmitField
。字段类从wtforms包导入,常用的WTForms字段如表:
通过实例化字段类时传入的参数,我们可以对字段进行设置,字段类构造方法接收的常用参数如表:
在WTForms
中,验证器(validator)
是一系列用于验证字段数据的类,我们在实例化字段类时使用validators关键字来指定附加的验证器列表。验证器从wtforms.validators
模块中导入,常用的验证器如表:
validators参数接收一个传入可调用对象组成的列表。内置的验证器通过实现了__call__()
方法的类表示,所以我们需要在验证器后添加括号。在name和password字段里,我们都使用了DataRequired
验证器,用来验证输入的数据是否有效。另外,password
字段里还添加了一个Length
验证器,用来验证输入的数据长度是否在给定的范围内。验证器的第一个参数一般为错误提示消息,我们可以使用message
关键字传递参数,通过传入自定义错误信息来覆盖内置消息,比如:
name = StringField('Your Name', validators=[DataRequired(message=u'名字不能为空!')])
4.2.2 输出HTML代码
以我们使用WTForms
创建的LoginForm
为例,实例化表单类,然后将实例属性转换成字符串或直接调用就可以获取表单字段对应的HTML代码:
>>> form = LoginForm()
>>> form.username()
u'<input id="username" name="username" type="text" value="">'
>>> form.submit()
u'<input id="submit" name="submit" type="submit" value="Submit">'
字段的<label>
元素的HTML代码则可以通过“form.
字段名.label”
的形式获取:
>>> form.username.label()
u'<label for="username">Username</label>'
>>> form.submit.label()
u'<label for="submit">Submit</label>'
在创建HTML表单时,我们经常会需要使用HTML<input>
元素的其他属性来对字段进行设置。比如,添加class属性设置对应的CSS类为字段添加样式;添加placeholder
属性设置占位文本。默认情况下,WTForms输出的字段HTML代码只会包含id和name属性,属性值均为表单类中对应的字段属性名称。如果要添加额外的属性,通常有两种方法。
1.使用render_kw属性
比如下面为username
字段使用render_kw
设置了placeholder HTML属性:
username = StringField('Username', render_kw={'placeholder': 'Your Username'})
这个字段被调用后输出的HTML代码如下所示:
<input type="text" id="username" name="username" placeholder="Your Username">
2.在调用字段时传入
在调用字段属性时,通过添加括号使用关键字参数的形式也可以传入字段额外的HTML属性:
>>> form.username(style='width: 200px;', class_='bar')
u'<i nput class="bar" id="username" name="username" style="width: 200px;" type="
class是Python的保留关键字,在这里我们使用class_来代替class,渲染后的会获得正确的class属性,在模板中调用时则可以直接使用class。
4.2.3 在模板中渲染表单
为了能够在模板中渲染表单,我们需要把表单类实例传入模板。首先在视图函数里实例化表单类LoginForm,然后在render_template()函数中使用关键字参数form将表单实例传入模板,代码form/app.py:传入表单类实例:
from forms import LoginForm # 导入LoginForm类
@app.route('/basic')
def basic():
form = LoginForm()
return render_template('login.html', form=form)
在模板中,只需要调用表单类的属性即可获取字段对应的HTML代码,如果需要传入参数,也可以添加括号,form/templates/basic.html:在模板中渲染表单:
<form method="post">
{{ form.csrf_token }} <!-- 渲染CSRF令牌隐藏字段 -->
{{ form.username.label }}{{ form.username }}<br>
{{ form.password.label }}{{ form.password }}<br>
{{ form.remember }}{{ form.remember.label }}<br>
{{ form.submit }}<br>
</form>
需要注意的是,在上面的代码中,除了渲染各个字段的标签和字段本身,我们还调用了form.csrf_token
属性渲染Flask-WTF
为表单类自动创建的CSRF令牌字段。form.csrf_token
字段包含了自动生成的CSRF令牌值,在提交表单后会自动被验证,为了确保表单通过验证,我们必须在表单中手动渲染这个字段。
Flask-WTF
为表单类实例提供了一个form.hidden_tag()
方法,这
个方法会依次渲染表单中所有的隐藏字段。因为csrf_token
字段也是隐
藏字段,所以当这个方法被调用时也会渲染csrf_token
字段。
渲染后获得的实际HTML代码如下所示:
<form method="post">
<input id="csrf_token" name="csrf_token" type="hidden" value="IjVmMDE1ZmFjM2VjYmZjY...<label for="username">Username</label><br>
<input id="username" name="username" type="text" value=""><br>
<label for="password">Password</label><br>
<input id="password" name="password" type="password" value=""><br>
<input id="remember" name="remember" type="checkbox" value="y"><label for="remember"><input id="submit" name="submit" type="submit" value="Log in"><br>
</form>
如果运行了示例程序,访问http://localhost:5000/basic可以看到渲
染后的表单,页面中的表单和我们在上面使用HTML编写的表单完全相
同。
在前面我们介绍过,使用render_kw
字典或是在调用字段时传入参数来定义字段的额外HTML属性,通过这种方式添加CSS类,我们可以编写一个Bootstrap
风格的表单, form/templates/bootstrap.html:渲染Bootstrap风格表单:
<form method="post">
{{ form.csrf_token }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class='form-control') }}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class='form-control') }}
</div>
<div class="form-check">
{{ form.remember(class='form-check-input') }}
{{ form.remember.label }}
</div>
{{ form.submit(class='btn btn-primary') }}
</form>
...
为了使用Bootstrap,我们在模板中加载了Bootstrap资源。如果你运行了示例程序,可以访问http://localhost:5000/bootstrap查看渲染后的实际效果:
4.3 处理表单数据
表单数据的处理涉及很多内容,除去表单提交不说,从获取数据到保存数据大致会经历以下步骤:
- 1)解析请求,获取表单数据。
- 2)对数据进行必要的转换,比如将勾选框的值转换成Python的布
尔值。 - 3)验证数据是否符合要求,同时验证CSRF令牌。
- 4)如果验证未通过则需要生成错误消息,并在模板中显示错误消
息。 - 5)如果通过验证,就把数据保存到数据库或做进一步处理。
除非是简单的程序,否则手动处理不太现实,使用Flask-WTF和
WTForms可以极大地简化这些步骤。
4.3.1 提交表单
在HTML中,当<form>
标签声明的表单中类型为submit
的提交字段被单击时,就会创建一个提交表单的HTTP请求,请求中包含表单各个字段的数据。表单的提交行为主要由三个属性控制:
form
标签的action
属性用来指定表单被提交的目标URL,默认为当前URL,也就是渲染该模板的路由所在的URL。如果你要把表单数据送到其他URL,可以自定义这个属性值。
当使用GET方法提交表单数据时,表单的数据会以查询字符串的形式附加在请求的URL里,比如:
http://localhost:5000/basic?username=greyli&password=12345
GET方式仅适用于长度不超过3000个字符,且不包含敏感信息的表单。因为这种方式会直接将用户提交的表单数据暴露在URL中,容易被攻击者截获,示例中的情况明显是危险的。因此,出于安全的考虑,我们一般使用POST方法提交表单。使用POST方法时,按照默认的编码类型,表单数据会被存储在请求主体中,比如:
POST /basic HTTP/1.0
...
Content-Type: application/x-www-form-urlencoded
Content-Length: 30
username=greyli&password=12345
,Flask为路由设置默认监听的HTTP方法为GET。为了支持接收表单提交发送的POST请求:
@app.route('/basic', methods=['GET', 'POST'])
def basic():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
flash('Welcome home, %s!' % username)
return redirect(url_for('index'))
return render_template('basic.html', form=form)
4.3.2 验证表单数据
表单数据的验证是Web表单中最重要的主题之一,这一节我们会学习如何使用Flask-WTF
验证并获取表单数据。
1.客户端验证和服务器端验证
表单的验证通常分为以下两种形式:
- (1)客户端验证
客户端验证(client side validation)是指在客户端(比如Web浏览
器)对用户的输入值进行验证。比如,使用HTML5内置的验证属性即
可实现基本的客户端验证(type、required、min、max、accept等)。比
如,下面的username字段添加了required标志:
<input type="text" name="username" required>
如果用户没有输入内容而按下提交按钮,会弹出浏览器内置的错误提示:
和其他附加HTML属性相同,我们可以在定义表单时通过render_kw
传入这些属性,或是在渲染表单时传入。像required这类布尔值属性,值可以为空或是任意ASCII字符,比如:
{{ form.username(required='') }}
除了使用HTML5提供的属性实现基本的客户端验证,我们通常会使用JavaScript
实现完善的验证机制。如果你不想手动编写JavaScript代码实现客户端验证,可以考虑使用各种JavaScript表单验证库,比如jQuery Validation Plugin
(https://jqueryvalidation.org/)、Parsley.js(http://parsleyjs.org/)以及可与Bootstrap
集成的BootstrapValidator
(http://1000hz.github.io/bootstrap-validator/,目前仅支持Bootstrap3版本)等。
客户端方式可以实时动态提示用户输入是否正确,只有用户输入正确后才会将表单数据发送到服务器。客户端验证可以增强用户体验,降低服务器负载。
- (2)服务器端验证
服务器端验证(server side validation)
是指用户把输入的数据提交到服务器端,在服务器端对数据进行验证。如果验证出错,就在返回的响应中加入错误信息。用户修改后再次提交表单,直到通过验证。我们在Flask程序中使用WTForms
实现的就是服务器端验证。
在这里我们不必纠结使用何种形式,因为无论你是否使用客户端验证,服务器端验证都是必不可少的,因为用户可以通过各种方式绕过客户端验证,比如在客户端设置禁用JavaScript。对于玩具程序来说,你可以不用考虑那么多,但对于真实项目来说,绝对不能忽视任何安全问题。因为客户端验证超出了本书介绍的范围,这里仅介绍服务器端验证的实现。
2.WTForms验证机制
WTForms验证表单字段的方式是在实例化表单类时传入表单数据,然后对表单实例调用validate()方法。这会逐个对字段调用字段实例时定义的验证器,返回表示验证结果的布尔值。如果验证失败,就把错误消息存储到表单实例的errors属性对应的字典中,验证的过程如下所示:
>>> from wtforms import Form, StringField, PasswordField, BooleanField
>>> from wtforms.validators import DataRequired, Length
>>> class LoginForm(Form):
... username = StringField('Username', validators=[DataRequired()])
... password = PasswordField('Password', validators=[DataRequired()
, Length(8, 128)])
>>> form = LoginForm(username='', password='123')
>>> form.data # 表单数据字典
{'username': '', 'password': '123'}
>>> form.validate()
False
>>> form.errors # 错误消息字典
{'username': [u'This field is required.'], 'password': [u'Field must be
at least 6 characters long.']}
>>> form2 = LoginForm(username='greyli', password='123456')
>>> form2.data
{'username': 'greyli', 'password': '123456'}
>>> form2.validate()
True
>>> form2.errors
{}
因为我们的表单使用POST方法提交,如果单纯使用WTForms
,我们在实例化表单类时需要首先把request.form
传入表单类,而使用Flask-WTF时,表单类继承的FlaskForm基类默认会从request.form
获取表单数据,所以不需要手动传入。
使用POST方法提交的表单,其数据会被Flask解析为一个字典,可以通过请求对象的form属性获取(request.form)
;使用GET方法提交的表单的数据同样会被解析为字典,不过要通过请求对象的args属性获取(request.args
)。
3.在视图函数中验证表单
因为现在的basic_form
视图同时接收两种类型的请求:GET请求和POST请求。所以我们要根据请求方法的不同执行不同的代码。具体来说:首先是实例化表单,如果是GET请求,那么就渲染模板;如果是POST请求,就调用validate()
方法验证表单数据。
请求的HTTP方法可以通过request.method属性获取,我们可以使用
下面的方式来组织视图函数:
from flask import request
@app.route('/basic', methods=['GET', 'POST'])
def basic():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
flash('Welcome home, %s!' % username)
return redirect(url_for('index'))
return render_template('basic.html', form=form)
其中的if语句等价于:
if 用户提交表单 and 数据通过验证:
获取表单数据并保存
当请求的方法是POST时(说明用户提交了表单),则验证表单数据。这会逐个字段(包括CSRF令牌字段)调用附加的验证器进行验证。
因为WTForms会自动对CSRF令牌字段进行验证,如果没有渲染该字段会导致验证出错,错误消息为“CSRF token is missing”。
表单类的data
属性是一个匹配所有字段与对应数据的字典,我们一般直接通过“form.字段属性名.data”
的形式来获取对应字段的数据。例如,form.username.data
返回username
字段的值。当表单验证成功后,我们获取了username
字段的数据,然后用来发送一条flash
消息,最后将程序重定向到index视图。
表单的数据一般会存储到数据库中。这里仅仅将数据填充到flash()函数里。
在浏览器中,当单击F5刷新/重载时的默认行为是发送上一个请求。如果上一个请求是POST请求,那么就会弹出一个确认窗口,询问用户是否再次提交表单。为了避免出现这个容易让人产生困惑的提示,我们尽量不要让提交表单的POST请求作为最后一个请求。这就是为什么我们在处理表单后返回一个重定向响应,这会让浏览器重新发送一个新的GET请求到重定向的目标URL。最终,最后一个请求就变成了GET请求。这种用来防止重复提交表单的技术称为PRG(Post/Redirect/Get)
模式,即通过对提交表单的POST请求返回重定向响应将最后一个请求转换为GET请求。
4.3.3 在模板中渲染错误消息
如果form.validate_on_submit()
返回False
,那么说明验证没有通过。对于验证未通过的字段,WTForms会把错误消息添加到表单类的errors属性中,这是一个匹配作为表单字段的类属性到对应的错误消息列表的字典。我们一般会直接通过字段名来获取对应字段的错误消息列表,即“form.字段名.errors”
。比如,form.name.errors
返回name
字段的错误消息列表。
form/templates/basic.html:渲染错误消息:
<form method="post">
{{ form.csrf_token }}
{{ form.username.label }}<br>
{{ form.username }}<br>
{% if form.username.errors %}
{% for message in form.username.errors %}
<small class="error">{{ message }}</small><br>
{% endfor %}
{% endif %}
{{ form.password.label }}<br>
{{ form.password }}<br>
{% if form.password.errors %}
{% for message in form.password.errors %}
<small class="error">{{ message }}</small><br>
{% endfor %}
{% endif %}
{{ form.remember }}{{ form.remember.label }}<br>
{{ form.submit }}<br>
</form>
为了让错误消息更加醒目,我们为错误消息元素添加了error类,这个样式类在style.css
文件中定义,它会将文字颜色设为红色。
如果你运行了示例程序,请访问http://localhost:5000/basic打开基本表单示例,如果你没有输入内容而按下提交按钮,会看到浏览器内置的错误提示:在这里插入图片描述
InputRequired
验证器和DataRequired
很相似,但InputRequired仅验证用户是否有输入,而不管输入的值是否有效。例如,由空格组成的数据也会通过验证。当使用DataRequired时,如果用户输入的数据不符合字段要求,比如在IntegerField输入非数字时会视为未输入,而不是类型错误。
表单处理流程图如下:
4.4 表单进阶实践
4.4.1 设置错误消息语言
WTForms内置了多种语言的错误消息,如果你想改变内置错误消息的默认语言,可以通过自定义表单基类实现(Flask-WTF版本>0.14.2)。
from flask_wtf import FlaskForm
app = Flask(__name__)
app.config['WTF_I18N_ENABLED'] = False
class MyBaseForm(FlaskForm):
class Meta:
locales = ['zh']
class HelloForm(MyBaseForm):
name = StringField('Name', validators=[DataRequired()])
submit = SubmitField()
首先,我们需要将配置变量WTF_I18N_ENABLED
设为False
,这会让Flask-WTF
使用WTForms
内置的错误消息翻译。然后我们需要在自定义基类中定义Meta
类,并在locales
列表中加入简体中文的地区字符串。在创建表单时,继承这个MyBaseForm
即可将错误消息语言设为中文,比如上面定义的HelloForm。
locales
属性是一个根据优先级排列的地区字符串列表。在WTForms中,简体中文和繁体中文的地区字符串分别为zh和zh_TW。
4.4.2 使用宏渲染表单
在模板中渲染表单时,我们有大量的工作要做:
- ·调用字段属性,获取定义。
- ·调用对应的label属性,获取
- ·渲染错误消息。
为了避免为每一个字段重复这些代码,我们可以创建一个宏来渲染表单字段:
{% macro form_field(field) %}
{{ field.label }}<br>
{{ field(**kwargs) }}<br>
{% if field.errors -%}
{% for error in field.errors -%}
<small class="error">{{ error }}</small><br>
{%- endfor %}
{%- endif %}
{% endmacro %}
这个form_field()
宏接收表单类实例的字段属性和附加的关键字参数作为输入,返回包含<label>
标签、表单字段、错误消息列表的HTML表单字段代码。使用这个宏渲染表单的示例如下所示:
{% from 'macros.html' import form_field %}
...
<form method="post">
{{ form.csrf_token }}
{{ form_field(form.username)}}<br>
{{ form_field(form.password) }}<br>
...
</form>
在上面的代码中,我们调用form_field()
宏逐个渲染表单中的字段,只要把每一个类属性传入form_field()
宏,即可完成渲染。
同样的,我们可以编写一个宏渲染Bootstrap风格的表单。不过,这类复杂的工作可以交给扩展来完成,后面我们会介绍使用扩展简化在模板中渲染Bootstrap风格表单的工作。
4.4.3 自定义验证器
在WTForms
中,验证器是指在定义字段时传入validators参数列表的可调用对象,这一节我们会介绍如何编写自定义验证器。
1.行内验证器
form/forms.py:针对特定字段的验证器:
from wtforms import IntegerField, SubmitField
from wtforms.validators import ValidationError
# custom validator
class FortyTwoForm(FlaskForm):
answer = IntegerField('The Number')
submit = SubmitField()
def validate_answer(form, field):
if field.data != 42:
raise ValidationError('Must be 42.')
当表单类中包含以“validate_字段属性名”形式命名的方法时,在验证字段数据时会同时调用这个方法来验证对应的字段,这也是为什么表单类的字段属性名不能以validate开头。验证方法接收两个位置参数,依次为form和field,前者为表单类实例,后者是字段对象,我们可以通过field.data获取字段数据,这两个参数将在验证表单时被调用传入。验证出错时抛出从wtforms.validators
模块导入的ValidationError
异常,传入错误消息作为参数。因为这种方法仅用来验证特定的表单类字段,所以又称为行内验证器(in-line validator)。
2.全局验证器
如果你想要创建一个可重用的通用验证器,可以通过定义一个函数实现。如果不需要传入参数定义验证器,那么一个和表单类中定义的验证方法完全相同的函数就足够了:
from wtforms.validators import ValidationError
def is_42(form, field):
if field.data != 42:
raise ValidationError('Must be 42')
class FortyTwoForm(FlaskForm):
answer = IntegerField('The Number', validators=[is_42])
submit = SubmitField()
当使用函数定义全局的验证器时,我们需要在定义字段时在validators列表里传入这个验证器。因为在validators列表中传入的验证器必须是可调用对象,所以这里传入了函数对象,而不是函数调用。
这仅仅是一个简单的示例,在现实中,我们通常需要让验证器支持传入参数来对验证过程进行设置。至少,我们应该支持message参数来设置自定义错误消息。这时验证函数应该实现成工厂函数,即返回一个可调用对象的函数.
from wtforms.validators import ValidationError
def is_42(message=None):
if message is None:
message = 'Must be 42.'
def _is_42(form, field):
if field.data != 42:
raise ValidationError(message)
return _is_42
class FortyTwoForm(FlaskForm):
answer = IntegerField('The Number', validators=[is_42()])
submit = SubmitField()
在现在的is_42()
函数中,我们创建了另一个_is_42()
函数,这个函数会被作为可调用对象返回。is_42()
函数接收的message参数用来传入自定义错误消息,默认为None,如果没有设置就使用内置消息。在validators列表中,这时需要传入的是对工厂函数is_42()
的调用。
在更复杂的验证场景下,你可以使用实现了__call__()方法的类(可调用类)来编写验证器,具体请参考WTForms文档相关章节(http://wtforms.readthedocs.io/en/latest/validators.html#customvalidators)
4.4.4 文件上传
在HTML中,渲染一个文件上传字段只需要将<input>
标签的type属性设为file,即。这会在浏览器中渲染成一个文件上传字段,单击文件选择按钮会打开文件选择窗口,选择对应的文件后,被选择的文件名会显示在文件选择按钮旁边。
在服务器端,可以和普通数据一样获取上传文件数据并保存。不过我们需要考虑安全问题,文件上传漏洞也是比较流行的攻击方式。除了常规的CSRF防范,我们还需要重点注意下面的问题:
- ·验证文件类型。
- ·验证文件大小。
- ·过滤文件名。
1.定义上传表单
在Python表单类中创建文件上传字段时,我们使用扩展Flask-WTF提供的FileField
类,它继承WTForms提供的上传字段FileField
,添加了对Flask的集成。
from flask wtf.file import FileField, FileRequired, FileAllowed
# upload form
class UploadForm(FlaskForm):
photo = FileField('Upload Image', validators=[FileRequired(), FileAllowed(['jpg', 'jpeg', 'png', 'gif'])])
submit = SubmitField()
为了便于测试,我们创建一个用来上传图片的photo字段。和其他字段类似,我们也需要对文件上传字段进行验证。Flask-WTF在flask_wtf.file模块下提供了两个文件相关的验证器.
我们使用FileRequired确保提交的表单字段中包含文件数据。出于安全考虑,我们必须对上传的文件类型进行限制。如果用户可以上传HTML文件,而且我们同时提供了视图函数获取上传后的文件,那么很容易导致XSS攻击。我们使用FileAllowed设置允许的文件类型,传入一个包含允许文件类型的后缀名列表。
Flask-WTF提供的FileAllowed是在服务器端验证上传文件,使用HTML5中的accept属性也可以在客户端实现简单的类型过滤。这个属性接收MIME类型字符串或文件格式后缀,多个值之间使用逗号分隔,比如:
<input type="file" id="profile_pic" name="profile_pic"
accept=".jpg, .jpeg, .png, .gif">
当用户单击文件选择按钮后,打开的文件选择窗口会默认将accept属性值之外的文件过滤掉。尽管如此,用户还是可以选择设定之外的文件,所以我们仍然需要进行服务器端验证。
扩展Flask-Uploads(https://github.com/maxcountryman/flaskuploads)内置了在Flask中实现文件上传的便利功能。Flask-WTF提供的FileAllowed()
也支持传入Flask-Uploads中的上传集对象(UploadSet)作为upload_set参数的值。另外,同类的扩展还有Flask-Transfer(https://github.com/justanr/Flask-Transfer)。除了验证文件的类型,我们通常还需要对文件大小进行验证,你肯定不想让用户上传超大的文件来拖垮你的服务器。通过设置Flask内置的配置变量MAX_CONTENT_LENGTH,我们可以限制请求报文的最大长度,单位为字节(byte)。比如,下面将最大长度限制为3M:
app.config['MAX_CONTENT_LENGTH'] = 3 * 1024 * 1024
当请求数据(上传文件大小)超过这个限制后,会返回413错误响应(Request Entity Too Large)
:
我们可以创建对应的错误处理函数来返回自定义的413错误响应。需要注意,Flask内置的开发服务器在抛出对应的异常时不会返回413响应,而是中断连接。不过我们不用担心这个问题,当使用生产环境下的服务器时,会正确返回413错误响应。
2.渲染上传表单
在新创建的upload视图里,我们实例化表单类UploadForm,然后传
入模板:
@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = UploadForm()
...
return render_template('upload.html', form=form)
form/templates/upload.html:在模板中渲染上传表单:
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token }}
{{ form_field(form.photo) }}
{{ form.submit }}
</form>
唯一需要注意的是,当表单中包含文件上传字段时(即type属性为file的input标签),需要将表单的enctype
属性设为"multipart/form-data"
,这会告诉浏览器将上传数据发送到服务器,否则仅会把文件名作为表单数据提交。
3.处理上传文件
和普通的表单数据不同,当包含上传文件字段的表单提交后,上传的文件需要在请求对象的files属性(request.files)
中获取。我们在第2章介绍过,这个属性是Werkzeug提供的ImmutableMultiDict
字典对象,存储字段的name键值和文件对象的映射,比如:
ImmutableMultiDict([('photo', <FileStorage: u'0f913b0ff95.JPG' ('image/jpeg')>)])
上传的文件会被Flask解析为Werkzeug中的FileStorage对象(werkzeug.datastructures.FileStorage)。当手动处理时,我们需要使用
文件上传字段的name属性值作为键获取对应的文件对象。比如:
request.files.get('photo')
当使用Flask-WTF时,它会自动帮我们获取对应的文件对象,这里我们仍然使用表单类属性的data属性获取上传文件。处理上传表单提交请求的upload视图如代码:
def random_filename(filename):
ext = os.path.splitext(filename)[1]
new_filename = uuid.uuid4().hex + ext
return new_filename
@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = UploadForm()
if form.validate_on_submit():
f = form.photo.data
filename = random_filename(f.filename)
f.save(os.path.join(app.config['UPLOAD_PATH'], filename))
flash('Upload success.')
session['filenames'] = [filename]
return redirect(url_for('show_images'))
return render_template('upload.html', form=form)
(1)使用原文件名
如果能够确定文件的来源安全,可以直接使用原文件名,通过FileStorage对象的filename属性获取:
filename = f.filename
(2)使用过滤后的文件名
我们可以使用Werkzeug提供的secure_filename()函数对文件名进行过滤,传递文件名作为参数,它会过滤掉所有危险字符,返回“安全的文件名”:
>>> from werkzeug import secure_filename
>>> secure_filename('avatar!@#//#\\%$^&.jpg')
'avatar.jpg'
>>> secure_filename('avatar头像.jpg')
'avatar.jpg'
(3)统一重命名
secure_filename()
函数非常方便,它会过滤掉文件名中的非ASCII字符。但如果文件名完全由非ASCII字符组成,那么会得到一个空文件名:
>>> secure_filename('头像.jpg')
'jpg'
为了避免出现这种情况,更好的做法是使用统一的处理方式对所有上传的文件重新命名。随机文件名有很多种方式可以生成,下面是一个使用Python内置的uuid模块生成随机文件名的random_filename()
函数:
def random_filename(filename):
ext = os.path.splitext(filename)[1]
new_filename = uuid.uuid4().hex + ext
return new_filename
这个函数接收原文件名作为参数,使用内置的uuid模块中的uuid4()
方法生成新的文件名,并使用hex属性获取十六进制字符串,最后返回包含后缀的新文件名。
处理完文件名后,是时候将文件保存到文件系统中了。我们在form目录下创建了一个uploads
文件夹,用于保存上传后的文件。指向这个文件夹的绝对路径存储在自定义配置变量UPLOAD_PATH
中:
app.config['UPLOAD_PATH'] = os.path.join(app.root_path, 'uploads')
这里的路径通过app.root_path
属性构造,它存储了程序实例所在脚本的绝对路径,相当于os.path.abspath(os.path.dirname(__file__))
。为了保存文件,你需要提前手动创建这个文件夹。
对FileStorage
对象调用save()
方法即可保存,传入包含目标文件夹绝对路径和文件名在内的完整保存路径:
f.save(os.path.join(app.config['UPLOAD_PATH'], filename))
文件保存后,我们希望能够显示上传后的图片。为了让上传后的文件能够通过URL获取,我们还需要创建一个视图函数来返回上传后的文件,如下所示:
@app.route('/uploads/<path:filename>')
def get_file(filename):
return send_from_directory(app.config['UPLOAD_PATH'], filename)
这个视图的作用与Flask内置的static视图类似,通过传入的文件路径返回对应的静态文件。在这个uploads视图中,我们使用Flask提供的
send_from_directory()
函数来获取文件,传入文件的路径和文件名作为参数。
在upload
视图里保存文件后,我们使用flash()
发送一个提示,将文件名保存到session中,最后重定向到show_images视图。show_images视图返回的uploaded.html模板中将从session获取文件名,渲染出上传后的图片:
flash('Upload success.')
session['filenames'] = [filename]
return redirect(url_for('show_images'))
这里将filename作为列表传入session只是为了兼容下面的多文件上传示例,这两个视图使用同一个模板,使用session可以在模板中统一从session获取文件名列表。
在uploaded.html模板里,我们将传入的文件名作为URL变量,通过上面的get_file
视图获取文件URL,作为<img>
标签的src属性值,如下所示:
<img src="{{ url_for('get_file', filename=filename) }}">
访问http://localhost:5000/upload打开文件上传示例,选择文件并提交后即可看到上传后的图片。另外,你会在示例程序文件夹中的uploads目录下发现上传的文件。
4.多文件上传
因为Flask-WTF
当前版本(0.14.2)中并未添加对多文件上传的渲染和验证支持,因此我们需要在视图函数中手动获取文件并进行验证。这种手动处理方式和我们在上一节介绍的方式效果基本相同。
在客户端,通过在文件上传字段(type=file)
加入multiple
属性,就可以开启多选:
<input type="file" id="file" name="file" multiple>
创建表单类时,可以直接使用WTForms
提供的MultipleFileField
字段实现,添加一个DataRequired
验证器来确保包含文件:
from wtforms import MultipleFileField
class MultiUploadForm(FlaskForm):
photo = MultipleFileField('Upload Image', validators={DataRequired()}
submit = SubmitField()
表单提交时,在服务器端的程序中,对request.files
属性调用getlist()
方法并传入字段的name
属性值会返回包含所有上传文件对象
的列表。在multi_upload
视图中,我们迭代这个列表,然后逐一对文件
进行处理:
form/app.py:处理多文件上传:
from flask import request, session, flash, redirect, url_for
from flask_wtf.csrf import validate_csrf
from wtforms import ValidationError
@app.route('/multi-upload', methods=['GET', 'POST'])
def multi_upload():
form = MultiUploadForm()
if request.method == 'POST':
filenames = []
# check csrf token
try:
validate_csrf(form.csrf_token.data)
except ValidationError:
flash('CSRF token error.')
return redirect(url_for('multi_upload'))
# check if the post request has the file part
if 'photo' not in request.files:
flash('This field is required.')
return redirect(url_for('multi_upload'))
for f in request.files.getlist('photo'):
# if user does not select file, browser also
# submit a empty part without filename
# if f.filename == '':
# flash('No selected file.')
# return redirect(url_for('multi_upload'))
# check the file extension
if f and allowed_file(f.filename):
filename = random_filename(f.filename)
f.save(os.path.join(
app.config['UPLOAD_PATH'], filename
))
filenames.append(filename)
else:
flash('Invalid file type.')
return redirect(url_for('multi_upload'))
flash('Upload success.')
session['filenames'] = filenames
return redirect(url_for('show_images'))
return render_template('upload.html', form=form)
在请求方法为POST时,我们对上传数据进行手动验证,主要包含下面几步:
- 1)手动调用
flask_wtf.csrf.validate_csrf
验证CSRF
令牌,传入表单中csrf_token隐藏字段的值。如果抛出wtforms.ValidationError
异常则表明验证未通过。 - 2)其中
if'photo'not in request.files
用来确保字段中包含文件数据(相当于FileRequired验证器)
,如果用户没有选择文件就提交表单则request.files将为空。 - 3)if f用来确保文件对象存在,这里也可以检查f是否是FileStorage实例。
- 4)
allowed_file(f.filename)
调用了allowed_file()
函数,传入文件名。这个函数相当于FileAllowed
验证器,用来验证文件类型,返回布尔值,如代码清单:
form/app.py:验证文件类型
app.config['ALLOWED_EXTENSIONS'] = ['png', 'jpg', 'jpeg', 'gif']
...
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
在上面的几个验证语句里,如果没有通过验证,我们使用flash()
函数显示错误消息,然后重定向到multi_upload
视图。
为了方便测试,我们还创建了一个临时的filenames列表,保存上传后的文件名到session中。访问http://localhost:5000/multi-upload打开多文件上传示例,单击按钮后可以选择多个文件,当上传的文件通过验证时,程序会重定向到show_images
视图,这个视图返回的uploaded.html
模板中将从session
获取所有文件名,渲染出所有上传后的图片。
在新版本的Flask-WTF发布后,可以使用和单文件上传相同的方式处理表单。比如,我们可以使用Flask-WTF提供的MultipleFileField
来创建提供Flask支持的多文件上传字段,使用相应的验证器对文件进行验证。在视图函数中,我们则可以继续使用form.validate_on_submit()
来验证表单,并通过form.photo.data来获取字段的数据——包含所有上传文件对象(werkzeug.datastructures.FileStorage)的列表。
多文件上传处理通常会使用JavaScript库在客户端进行预验证,并添加进度条来优化用户体验。可在后续实战篇学习。
4.4.5 多文件拖拽上传
form/app.py
@app.route('/dropzone-upload', methods=['GET', 'POST'])
def dropzone_upload():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
return 'This field is required.', 400
f = request.files.get('file')
if f and allowed_file(f.filename):
filename = random_filename(f.filename)
f.save(os.path.join(
app.config['UPLOAD_PATH'], filename
))
else:
return 'Invalid file type.', 400
return render_template('dropzone.html')
templates/dropzone.html
{% extends 'base.html' %}
{% block head %}
{{ super() }}
{{ dropzone.load() }}
{{ dropzone.style('border: 2px dashed #0087F7; margin: 10%; min-height: 300px;') }}
{% endblock %}
{% block content %}
<h2>Integrate Dropzone.js with Flask-Dropzone</h2>
{{ dropzone.create(action_view='dropzone_upload') }}
{% endblock %}
访问http://localhost:5000/dropzone-upload,打开拖拽上传示例:
件上传示例
4.4.6 使用Flask-CKEditor集成富文本编辑器
富文本编辑器即WYSIWYG(What You See Is What You Get,所见即所得)编辑器,类似于我们经常使用的文本编辑软件。它提供一系列按钮和下拉列表来为文本设置格式,编辑状态的文本样式即最终呈现出来的样式。在Web程序中,这种编辑器也称为HTML富文本编辑器,因为它使用HTML标签来为文本定义样式。
CKEditor(http://ckeditor.com/)是一个开源的富文本编辑器,它包含丰富的配置选项,而且有大量第三方插件支持。扩展Flask-CKEditor
简化了在Flask程序中使用CKEditor
的过程,我们将使用它来集成CKEditor。首先使用Pipenv安装:
$ pipenv install flask-ckeditor
然后实例化Flask-CKEditor提供的CKEditor类,传入程序实例:
from flask_ckeditor import CKEditor
ckeditor = CKEditor(app)
1.配置富文本编辑器
Flask-CKEditor提供了许多配置变量来对编辑器进行设置,常用的配置及其说明如表:
完整的可用配置列表见Flask-CKEditor文档的配置部分:https://flask-ckeditor.readthedocs.io/en/latest/configuration.html在示例程序中,为了方便开发,使用了内置的本地资源:
app.config['CKEDITOR_SERVE_LOCAL'] = True
Flask-CKEditor内置了对常用第三方CKEditor插件的支持,你可以轻松地为编辑器添加图片上传与插入、插入语法高亮代码片段、Markdown编辑模式等功能,具体可以访问Flask-CKEditor文档的插件集成部分(https://flask-ckeditor.readthedocs.io/en/latest/plugins.html)。要使用这些功能,需要在CKEditor包中安装对应的插件,Flask-CKEditor内置的资源已经包含了这些插件,你可以通过Flask-CKEditor提供的示例程序(https://github.com/greyli/flask-ckeditor/tree/master/examples)来了解这些功能的具体实现。
2.渲染富文本编辑器
富文本编辑器在HTML中通过文本区域字段表示,即<textarea></textarea>
。Flask-CKEditor通过包装WTForms提供的TextAreaField
字段类型实现了一个CKEditorField
字段类,我们使用它来构建富文本编辑框字段。
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length
from flask_ckeditor import CKEditorField # 从flask_ckeditor包导入
class RichTextForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(1, 50)])
body = CKEditorField('Body', validators=[DataRequired()])
submit = SubmitField('Publish')
文章正文字段(body)使用的CKEditorField字段类型从Flask-CKEditor导入。我们可以像其他字段一样定义标签、验证器和默认值。在使用上,这个字段和WTForms内置的其他字段完全相同。比如,在提交表单时,同样使用data属性获取数据。在模板中,渲染这个body字段的方式和其他字段也完全相同,在示例程序中,我们在模板ckeditor.html渲染了这个表单,如代码清单,form/templates/ckeditor.html:渲染包含CKEditor编辑器的表单:
{% extends 'base.html' %}
{% from 'macros.html' import form_field %}
{% block content %}
<h2>Integrate <a href="https://ckeditor.com">CKEditor</a> with <a href="https://github.com/greyli/flask-ckeditor">Flask-CKEditor</a>
</h2>
<form method="post">
{{ form.csrf_token }}
{{ form_field(form.title) }}
{{ form_field(form.body) }}
{{ form.submit }}
</form>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ ckeditor.load() }}
{{ ckeditor.config(name='body') }}
{% endblock %}
渲染CKEditor编辑器需要加载相应的JavaScript脚本。在开发时,为了方便开发,可以使用Flask-CKEditor在模板中提供的ckeditor.load(方法加载资源,它默认从CDN加载资源,将CKEDITOR_SERVE_LOCAL设为Ture会使用扩展内置的本地资源,内置的本地资源包含了几个常用的插件和语言包。ckeditor.load()方法支持通过pkg_type参数传入包类型,这会覆盖配置CKEDITOR_PKG_TYPE的值,额外的version参数可以设置从CDN加载的CKEditor版本。
作为替代,你可以访问CKEditor官网提供的构建工具
(https://ckeditor.com/cke4/builde)构建自己的CKEditor包,下载后放到static目录下,然后在需要显示文本编辑器的模板中加载包目录下的ckeditor.js文件,替换掉ckeditor.load()调用。
如果你使用配置变量设置了编辑器的高度、宽度和语言或是其他插件配置,需要使用ckeditor.config()
方法加载配置,传入对应表单字段的name属性值,即对应表单类属性名。这个方法需要在加载CKEditor资源后调用:
{{ ckeditor.config(name='body') }}
为了支持为不同页面上的编辑器字段或单个页面上的多个编辑器字段使用不同的配置,大多数配置键都可以通过相应的关键字在ckeditor.config()方法中传入,比如language、height、width等,这些参数会覆盖对应的全局配置。另外,Flask-CKEditor也允许你传入自定义配置字符串,更多详情可访问Flask-CKEditor文档的配置部分(https://flask-ckeditor.readthedocs.io/configuration.html)。访问http://localhost:5000/ckeditor可以看到渲染后的富文本编辑器:
如果你不使用Flask-WTF/WTForms,Flask-CKEditor
还提供了一个在模板中直接创建文本编辑器字段的ckeditor.create()
方法,具体用法参考相关文档。
4.4.7 单个表单多个提交按钮
在某些情况下,我们可能需要为一个表单添加多个提交按钮。比如在创建文章的表单中添加发布新文章和保存草稿的按钮。当用户提交表单时,我们需要在视图函数中根据按下的按钮来做出不同的处理。下面代码清单创建了一个这样的表单,其中save表示保存草稿按钮,publish表示发布按钮,正文字段使用TextAreaField字段。
form/forms.py:包含两个提交按钮的表单
class NewPostForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(1, 50)])
body = TextAreaField('Body', validators=[DataRequired()])
save = SubmitField('Save') # 保存按钮
publish = SubmitField('Publish') # 发布按钮
当表单数据通过POST请求提交时,Flask会把表单数据解析到request.form字典。如果表单中有两个提交字段,那么只有被单击的提交字段才会出现在这个字典中。当我们对表单类实例或特定的字段属性调用data属性时,WTForms会对数据做进一步处理。对于提交字段的值,它会将其转换为布尔值:被单击的提交字段的值将是True,未被单击的值则是False。
基于这个机制,我们可以通过提交按钮字段的值来判断当前被单击的按钮,如代码清单: form/app.py:判断被单击的提交按钮
@app.route('/two-submits', methods=['GET', 'POST'])
def two_submits():
form = NewPostForm()
if form.validate_on_submit():
if form.save.data:
# save it...
flash('You click the "Save" button.')
elif form.publish.data:
# publish it...
flash('You click the "Publish" button.')
return redirect(url_for('index'))
return render_template('2submit.html', form=form)
访问http://localhost:5000/two-submits,当你单击某个按钮时,重定向后的页面的提示信息中会包含你单击的按钮名称。
4.4.8 单个页面多个表单
除了在单个表单上实现多个提交按钮,有时我们还需要在单个页面上创建多个表单。比如,在程序的主页上同时添加登录和注册表单。当在同一个页面上添加多个表单时,我们要解决的一个问题就是在视图函数中判断当前被提交的是哪个表单。
1.单视图处理
创建两个表单,并在模板中分别渲染并不是难事,但是当提交某个表单时,我们就会遇到问题。Flask-WTF根据请求方法判断表单是否提交,但并不判断是哪个表单被提交,所以我们需要手动判断。基于上一节介绍的内容,我们知道被单击的提交字段最终的data属性值是布尔值,即True或False。而解析后的表单数据使用input字段的name属性值作为键匹配字段数据,也就是说,如果两个表单的提交字段名称都是submit,那么我们也无法判断是哪个表单的提交字段被单击。
解决问题的第一步就是为两个表单的提交字段设置不同的名称,示例程序中的这两个表单如代码清单,form/forms.py:为两个表单设置不同的提交字段名称:
class SigninForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 20)])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
submit1 = SubmitField('Sign in')
class RegisterForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 20)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(1, 254)])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
submit2 = SubmitField('Register')
在视图函数中,我们分别实例化这两个表单,根据提交字段的值来区分被提交的表单,如代码清单,form/app.py:在视图函数中处理多个表单:
@app.route('/multi-form', methods=['GET', 'POST'])
def multi_form():
signin_form = SigninForm()
register_form = RegisterForm()
if signin_form.submit1.data and signin_form.validate():
username = signin_form.username.data
flash('%s, you just submit the Signin Form.' % username)
return redirect(url_for('index'))
if register_form.submit2.data and register_form.validate():
username = register_form.username.data
flash('%s, you just submit the Register Form.' % username)
return redirect(url_for('index'))
return render_template('2form.html', signin_form=signin_form, register_form=register_form)
在视图函数中,我们为两个表单添加了各自的if判断,在这两个if语句的内部,我们分别执行各自的代码逻辑。以登录表单(SigninForm)的if判断为例,如果signin_form.submit1.data的值为True,那就说明用户提交了登录表单,这时我们手动调用signin_form.validate()对这个表单进行验证。
这两个表单类实例通过不同的变量名称传入模板,以便在模板中相
应渲染对应的表单字段,如下所示:
...
<form method="post">
{{ signin_form.csrf_token }}
{{ form_field(signin_form.username) }}
{{ form_field(signin_form.password) }}
{{ signin_form.submit1 }}
</form>
<h2>Register Form</h2>
<form method="post">
{{ register_form.csrf_token }}
{{ form_field(register_form.username) }}
{{ form_field(register_form.email) }}
{{ form_field(register_form.password) }}
{{ register_form.submit2 }}
</form>
...
访问http://localhost:5000/multi-form打开示例页面,当提交某个表单后,你会在重定向后的页面的提示消息里看到提交表单的名称。
2.多视图处理
除了通过提交按钮判断,更简洁的方法是通过分离表单的渲染和验证实现。这时表单的提交字段可以使用同一个名称,在视图函数中处理表单时也只需使用我们熟悉的form.validate_on_submit()
方法。
在介绍表单处理时,我们在同一个视图函数内处理两类工作:渲染包含表单的模板(GET请求)、处理表单请求(POST请求)。如果你想解耦这部分功能,那么也可以分离成两个视图函数处理。当处理多个表单时,我们可以把表单的渲染在单独的视图函数中处理,如下所示:
@app.route('/multi-form-multi-view')
def multi_form_multi_view():
signin_form = SigninForm2()
register_form = RegisterForm2()
return render_template('2form2view.html', signin_form=signin_form, register_form=register_form)
这个视图只负责处理GET请求,实例化两个表单类并渲染模板。另外我们再为每一个表单单独创建一个视图函数来处理验证工作。处理表单提交请求的视图仅监听POST请求,如代码清单: form/app.py:使用单独的视图函数处理表单提交的POST请求:
@app.route('/handle-signin', methods=['POST'])
def handle_signin():
signin_form = SigninForm2()
register_form = RegisterForm2()
if signin_form.validate_on_submit():
username = signin_form.username.data
flash('%s, you just submit the Signin Form.' % username)
return redirect(url_for('index'))
return render_template('2form2view.html', signin_form=signin_form, register_form=register_form)
@app.route('/handle-register', methods=['POST'])
def handle_register():
signin_form = SigninForm2()
register_form = RegisterForm2()
if register_form.validate_on_submit():
username = register_form.username.data
flash('%s, you just submit the Register Form.' % username)
return redirect(url_for('index'))
return render_template('2form2view.html', signin_form=signin_form, register_form=register_form)
在HTML中,表单提交请求的目标URL通过action属性设置。为了让表单提交时将请求发送到对应的URL,我们需要设置action属性,如下所示
...
<h2>Login Form</h2>
<form method="post" action="{{ url_for('handle_signin') }}">
...
</form>
<h2>Register Form</h2>
<form method="post" action="{{ url_for('handle_register') }}">
...
</form>
...
虽然现在可以正常工作,但是这种方法有一个显著的缺点。如果验证未通过,你需要将错误消息的form.errors字典传入模板中。在处理表单的视图中传入表单错误信息,就意味着需要再次渲染模板,但是如果视图函数中还涉及大量要传入模板的变量操作,那么这种方式会带来大量的重复。
对于这个问题,一般的解决方式是通过其他方式传递错误消息,然后统一重定向到渲染表单页面的视图。比如,使用flash()函数迭代form.errors字典发送错误消息(这个字典包含字段名称与错误消息列表的映射),然后重定向到用来渲染表单的multi_form_multi_view视图。下面是一个使用flash()函数来发送表单错误消息的便利函数:
def flash_errors(form):
for field, errors in form.errors.items():
for error in errors:
flash(u"Error in the %s field - %s" % (
getattr(form, field).label.text,error))
如果你希望像往常一样在表单字段下渲染错误消息,可以直接将错误消息字典form.errors存储到session中,然后重定向到用来渲染表单的multi_form_multi_view视图。在模板中渲染表单字段错误时添加一个额外的判断,从session中获取并迭代错误消息。
4.5 本章代码
4.5.1 app.py
# -*- coding: utf-8 -*-
import os
import uuid
from flask import Flask, render_template, flash, redirect, url_for, request, send_from_directory, session
from flask_ckeditor import CKEditor, upload_success, upload_fail
from flask_dropzone import Dropzone
from flask_wtf.csrf import validate_csrf
from wtforms import ValidationError
from forms import LoginForm, FortyTwoForm, NewPostForm, UploadForm, MultiUploadForm, SigninForm, \
RegisterForm, SigninForm2, RegisterForm2, RichTextForm
app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'secret string')
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# Custom config
app.config['UPLOAD_PATH'] = os.path.join(app.root_path, 'uploads')
app.config['ALLOWED_EXTENSIONS'] = ['png', 'jpg', 'jpeg', 'gif']
# Flask config
# set request body's max length
# app.config['MAX_CONTENT_LENGTH'] = 3 * 1024 * 1024 # 3Mb
# Flask-CKEditor config
app.config['CKEDITOR_SERVE_LOCAL'] = True
app.config['CKEDITOR_FILE_UPLOADER'] = 'upload_for_ckeditor'
# Flask-Dropzone config
app.config['DROPZONE_ALLOWED_FILE_TYPE'] = 'image'
app.config['DROPZONE_MAX_FILE_SIZE'] = 3
app.config['DROPZONE_MAX_FILES'] = 30
ckeditor = CKEditor(app)
dropzone = Dropzone(app)
@app.route('/', methods=['GET', 'POST'])
def index():
return render_template('index.html')
@app.route('/html', methods=['GET', 'POST'])
def html():
form = LoginForm()
if request.method == 'POST':
username = request.form.get('username')
flash('Welcome home, %s!' % username)
return redirect(url_for('index'))
return render_template('pure_html.html')
@app.route('/basic', methods=['GET', 'POST'])
def basic():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
flash('Welcome home, %s!' % username)
return redirect(url_for('index'))
return render_template('basic.html', form=form)
@app.route('/bootstrap', methods=['GET', 'POST'])
def bootstrap():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
flash('Welcome home, %s!' % username)
return redirect(url_for('index'))
return render_template('bootstrap.html', form=form)
@app.route('/custom-validator', methods=['GET', 'POST'])
def custom_validator():
form = FortyTwoForm()
if form.validate_on_submit():
flash('Bingo!')
return redirect(url_for('index'))
return render_template('custom_validator.html', form=form)
@app.route('/uploads/<path:filename>')
def get_file(filename):
return send_from_directory(app.config['UPLOAD_PATH'], filename)
@app.route('/uploaded-images')
def show_images():
return render_template('uploaded.html')
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
def random_filename(filename):
ext = os.path.splitext(filename)[1]
new_filename = uuid.uuid4().hex + ext
return new_filename
@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = UploadForm()
if form.validate_on_submit():
f = form.photo.data
filename = random_filename(f.filename)
f.save(os.path.join(app.config['UPLOAD_PATH'], filename))
flash('Upload success.')
session['filenames'] = [filename]
return redirect(url_for('show_images'))
return render_template('upload.html', form=form)
@app.route('/multi-upload', methods=['GET', 'POST'])
def multi_upload():
form = MultiUploadForm()
if request.method == 'POST':
filenames = []
# check csrf token
try:
validate_csrf(form.csrf_token.data)
except ValidationError:
flash('CSRF token error.')
return redirect(url_for('multi_upload'))
# check if the post request has the file part
if 'photo' not in request.files:
flash('This field is required.')
return redirect(url_for('multi_upload'))
for f in request.files.getlist('photo'):
# if user does not select file, browser also
# submit a empty part without filename
# if f.filename == '':
# flash('No selected file.')
# return redirect(url_for('multi_upload'))
# check the file extension
if f and allowed_file(f.filename):
filename = random_filename(f.filename)
f.save(os.path.join(
app.config['UPLOAD_PATH'], filename
))
filenames.append(filename)
else:
flash('Invalid file type.')
return redirect(url_for('multi_upload'))
flash('Upload success.')
session['filenames'] = filenames
return redirect(url_for('show_images'))
return render_template('upload.html', form=form)
@app.route('/dropzone-upload', methods=['GET', 'POST'])
def dropzone_upload():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
return 'This field is required.', 400
f = request.files.get('file')
if f and allowed_file(f.filename):
filename = random_filename(f.filename)
f.save(os.path.join(
app.config['UPLOAD_PATH'], filename
))
else:
return 'Invalid file type.', 400
return render_template('dropzone.html')
@app.route('/two-submits', methods=['GET', 'POST'])
def two_submits():
form = NewPostForm()
if form.validate_on_submit():
if form.save.data:
# save it...
flash('You click the "Save" button.')
elif form.publish.data:
# publish it...
flash('You click the "Publish" button.')
return redirect(url_for('index'))
return render_template('2submit.html', form=form)
@app.route('/multi-form', methods=['GET', 'POST'])
def multi_form():
signin_form = SigninForm()
register_form = RegisterForm()
if signin_form.submit1.data and signin_form.validate():
username = signin_form.username.data
flash('%s, you just submit the Signin Form.' % username)
return redirect(url_for('index'))
if register_form.submit2.data and register_form.validate():
username = register_form.username.data
flash('%s, you just submit the Register Form.' % username)
return redirect(url_for('index'))
return render_template('2form.html', signin_form=signin_form, register_form=register_form)
@app.route('/multi-form-multi-view')
def multi_form_multi_view():
signin_form = SigninForm2()
register_form = RegisterForm2()
return render_template('2form2view.html', signin_form=signin_form, register_form=register_form)
@app.route('/handle-signin', methods=['POST'])
def handle_signin():
signin_form = SigninForm2()
register_form = RegisterForm2()
if signin_form.validate_on_submit():
username = signin_form.username.data
flash('%s, you just submit the Signin Form.' % username)
return redirect(url_for('index'))
return render_template('2form2view.html', signin_form=signin_form, register_form=register_form)
@app.route('/handle-register', methods=['POST'])
def handle_register():
signin_form = SigninForm2()
register_form = RegisterForm2()
if register_form.validate_on_submit():
username = register_form.username.data
flash('%s, you just submit the Register Form.' % username)
return redirect(url_for('index'))
return render_template('2form2view.html', signin_form=signin_form, register_form=register_form)
@app.route('/ckeditor', methods=['GET', 'POST'])
def integrate_ckeditor():
form = RichTextForm()
if form.validate_on_submit():
title = form.title.data
body = form.body.data
flash('Your post is published!')
return render_template('post.html', title=title, body=body)
return render_template('ckeditor.html', form=form)
# handle image upload for ckeditor
@app.route('/upload-ck', methods=['POST'])
def upload_for_ckeditor():
f = request.files.get('upload')
if not allowed_file(f.filename):
return upload_fail('Image only!')
f.save(os.path.join(app.config['UPLOAD_PATH'], f.filename))
url = url_for('get_file', filename=f.filename)
return upload_success(url, f.filename)
4.5.2 forms.py
# -*- coding: utf-8 -*-
from flask_ckeditor import CKEditorField
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import StringField, PasswordField, BooleanField, IntegerField, \
TextAreaField, SubmitField, MultipleFileField
from wtforms.validators import DataRequired, Length, ValidationError, Email
# 4.3.1 basic form example
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
remember = BooleanField('Remember me')
submit = SubmitField('Log in')
# custom validator
class FortyTwoForm(FlaskForm):
answer = IntegerField('The Number')
submit = SubmitField()
def validate_answer(form, field):
if field.data != 42:
raise ValidationError('Must be 42.')
# upload form
class UploadForm(FlaskForm):
photo = FileField('Upload Image', validators=[FileRequired(), FileAllowed(['jpg', 'jpeg', 'png', 'gif'])])
submit = SubmitField()
# multiple files upload form
class MultiUploadForm(FlaskForm):
photo = MultipleFileField('Upload Image', validators=[DataRequired()])
submit = SubmitField()
# multiple submit button
class NewPostForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(1, 50)])
body = TextAreaField('Body', validators=[DataRequired()])
save = SubmitField('Save')
publish = SubmitField('Publish')
class SigninForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 20)])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
submit1 = SubmitField('Sign in')
class RegisterForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 20)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(1, 254)])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
submit2 = SubmitField('Register')
class SigninForm2(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 24)])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
submit = SubmitField()
class RegisterForm2(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 24)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(1, 254)])
password = PasswordField('Password', validators=[DataRequired(), Length(8, 128)])
submit = SubmitField()
# CKEditor Form
class RichTextForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(1, 50)])
body = CKEditorField('Body', validators=[DataRequired()])
submit = SubmitField('Publish')