在第8章中,我做了一些必要的数据库更改,以支持在社交网络中非常流行的“关注者”范例。 有了该功能之后,我就可以删除在开头放置的最后一块脚手架,即假帖子。 在本章中,该应用程序将开始接受来自用户的博客文章,并在主页和个人资料页面中进行发布。
提交博客文章
让我们从简单的事情开始。 主页需要具有一种表单,用户可以在其中键入新帖子。 首先,我创建一个表单类:
# app/forms.py: Blog submission form.
class PostForm(FlaskForm):
post = TextAreaField('Say something', validators=[
DataRequired(), Length(min=1, max=140)])
submit = SubmitField('Submit')
接下来,我可以将此表单添加到应用程序主页的模板中:
<!-- app/templates/index.html: Post submission form in index template -->
{% extends "base.html" %}
{% block content %}
<h1>Hi, {{ current_user.username }}!</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.post.label }}<br>
{{ form.post(cols=32, rows=4) }}<br>
{% for error in form.post.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
{% for post in posts %}
<p>
{{ post.author.username }} says: <b>{{ post.body }}</b>
</p>
{% endfor %}
{% endblock %}
该模板中的更改与以前的表单处理方式相似。 最后一部分是在view函数中添加表单创建和处理:
# app/routes.py: Post submission form in index view function.
from app.forms import PostForm
from app.models import Post
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
form = PostForm()
if form.validate_on_submit():
post = Post(body=form.post.data, author=current_user)
db.session.add(post)
db.session.commit()
flash('Your post is now live!')
return redirect(url_for('index'))
posts = [
{
'author': {'username': 'John'},
'body': 'Beautiful day in Portland!'
},
{
'author': {'username': 'Susan'},
'body': 'The Avengers movie was so cool!'
}
]
return render_template("index.html", title='Home Page', form=form,
posts=posts)
让我们一一回顾一下此视图功能中的更改:
- 我现在要导入
Post
和PostForm
类 - 除了
GET
请求外,我在与index
视图功能关联的两条路由中都接受POST
请求,因为该视图功能现在将接收表单数据。 - 表单处理逻辑将新的
Post
记录插入数据库。 - 模板接收
form
对象作为附加参数,以便它可以呈现文本字段。
在继续之前,我想提到与处理Web表单有关的重要事项。请注意,在处理表单数据之后,如何通过发出重定向到主页来结束请求。我本可以轻松地跳过重定向,并允许该函数继续向下进入模板呈现部分,因为这已经是索引视图函数。
那么,为什么要重定向?这是一种标准做法,它通过重定向来响应由Web表单提交生成的POST
请求。这有助于减轻在Web浏览器中如何执行refresh命令的麻烦。当你按下刷新键时,Web浏览器所做的就是重新发出上一个请求。如果提交表单的POST
请求返回常规响应,则刷新将重新提交表单。因为这是意外的,所以浏览器将要求用户确认重复提交,但是大多数用户将无法理解浏览器在问他们什么。但是,如果通过重定向答复了POST
请求,则现在指示浏览器发送GET
请求以获取重定向中指示的页面,因此现在最后一个请求不再是POST
请求,并且refresh命令工作在更可预测的方式。
这个简单的技巧称为Post/Redirect/Get模式。当用户在提交Web表单后无意刷新页面时,它避免了插入重复的帖子。
显示博客文章
如果你还记得的话,我创建了一些虚假的博客文章,这些文章已经在首页上显示了很长时间了。 这些假对象在index
视图函数中作为简单的Python列表显式创建:
posts = [
{
'author': {'username': 'John'},
'body': 'Beautiful day in Portland!'
},
{
'author': {'username': 'Susan'},
'body': 'The Avengers movie was so cool!'
}
]
但是现在,我在User
模型中有了followed_posts()
方法,该方法返回给定用户想要查看的帖子的查询。 因此,现在我可以将假帖子替换为真实帖子:
# app/routes.py: Display real posts in home page.
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
# ...
posts = current_user.followed_posts().all()
return render_template("index.html", title='Home Page', form=form,
posts=posts)
User
类的followed_posts
方法返回一个SQLAlchemy查询对象,该对象配置为从数据库中获取用户感兴趣的帖子。 在此查询上调用all()
会触发其执行,返回值是包含所有结果的列表。 因此,我最终得到的结构与到现在为止我一直在使用的虚假帖子非常相似。 如此接近以至于模板甚至都不需要更改。
让找到想关注的用户变得更容易
你肯定会注意到,该应用程序在让用户找到其他用户时并不能很好地发挥作用。 实际上,根本没有办法查看其他用户。 我将通过一些简单的更改来解决这个问题。
我将创建一个新页面,称为“浏览”页面。 该页面将像主页一样工作,但不仅会显示来自关注用户的帖子,还将显示所有用户的全局帖子流。 这是新的explore视图功能:
# app/routes.py: Explore view function.
@app.route('/explore')
@login_required
def explore():
posts = Post.query.order_by(Post.timestamp.desc()).all()
return render_template('index.html', title='Explore', posts=posts)
你是否注意到此视图功能有些奇怪? render_template()
调用引用了index.html
模板,我正在应用程序的主页中使用该模板。 由于此页面与主页非常相似,因此我决定重用该模板。 但是与主页的不同之处在于,在浏览页面中,我不希望使用表单来撰写博客文章,因此,在此视图功能中,我没有在模板调用中包含form
参数。
为了防止index.html
模板在尝试呈现不存在的Web表单时崩溃,我将添加一个条件,该条件仅在定义了表单的情况下才会呈现:
<!-- app/templates/index.html: Make the blog post submission form optional. -->
{% extends "base.html" %}
{% block content %}
<h1>Hi, {{ current_user.username }}!</h1>
{% if form %}
<form action="" method="post">
...
</form>
{% endif %}
...
{% endblock %}
我还将在导航栏中添加指向该新页面的链接:
<!-- app/templates/base.html: Link to explore page in navigation bar. -->
<a href="{{ url_for('explore') }}">Explore</a>
还记得我在第6章中介绍的_post.html
子模板,以在用户个人资料页面中呈现博客文章吗? 这是一个很小的模板,包含在用户个人资料页面模板中,并且是单独的,因此也可以与其他模板一起使用。 现在,我将对其进行一些小的改进,以显示博客文章作者的用户名作为链接:
<!-- app/templates/_post.html: Show link to author in blog posts. -->
<table>
<tr valign="top">
<td><img src="{{ post.author.avatar(36) }}"></td>
<td>
<a href="{{ url_for('user', username=post.author.username) }}">
{{ post.author.username }}
</a>
says:<br>{{ post.body }}
</td>
</tr>
</table>
现在,我可以使用此子模板在主页中呈现博客文章并浏览页面:
<!-- app/templates/index.html: Use blog post sub-template. -->
...
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
...
子模板期望存在一个名为post
的变量,这就是index模板中循环变量的命名方式,因此可以完美地工作。
通过这些小的更改,应用程序的可用性得到了很大的提高。 现在,用户可以访问浏览页面以读取未知用户的博客文章,并根据这些文章找到新的用户,只需单击用户名即可访问个人资料页面。 太好了吧?
在这一点上,我建议你再次尝试该应用程序,以便体验这些最后的用户界面改进。
博客文章分页
该应用程序看上去比以往任何时候都更好,但是在首页中显示所有后续帖子将早晚成为问题。 如果用户有一千个关注帖子,会发生什么? 或者一百万个? 可以想象,管理如此众多的帖子将非常缓慢且效率低下。
为了解决这个问题,我将对帖子列表进行分页。 这意味着一开始我将只显示有限数量的帖子,并包含用于导航整个帖子列表的链接。 Flask-SQLAlchemy本机支持使用paginate()
查询方法进行分页。 例如,如果我想获得该用户的前二十个帖子,则可以将终止查询的all()
调用替换为:
>>> user.followed_posts().paginate(1, 20, False).items
可以在Flask-SQLAlchemy中的任何查询对象上调用paginate
方法。 它包含三个参数:
- 页码,从1开始
- 每页的项目数
- 错误标志。 如果为
True
,则当请求超出范围的页面时,将自动向客户端返回404错误。 如果为False
,则超出范围的页面将返回一个空列表。
来自paginate
的返回值是一个Pagination
对象。 该对象的items
属性包含请求页面中的项目列表。 Pagination
对象中还有其他有用的东西,我将在后面讨论。
现在,让我们考虑一下如何在index()
视图函数中实现分页。 我可以从向应用程序添加配置项开始,该配置项确定每页将显示多少项。
# config.py: Posts per page configuration.
class Config(object):
# ...
POSTS_PER_PAGE = 3
拥有这些可以通过配置文件更改应用程序范围的行为的“旋钮”是一个好主意,因为这样我就可以到一个地方进行调整。当然,在最终应用程序中,每页我将使用大于三项的数字,但是对于测试,使用小数字很有用。
接下来,我需要确定如何将页码合并到应用程序URL中。一种相当普遍的方法是使用查询字符串参数来指定可选的页码,如果未指定,则默认为第1页。以下是一些示例URL,它们显示了我将如何实现此目标:
- 第1页,隐式:http://localhost:5000/index
- 第1页,明确:http://localhost:5000/index?page=1
- 第3页:http://localhost:5000/index?page=3
要访问查询字符串中给出的参数,我可以使用Flask的request.args
对象。你已经在第5章中看到了这一点,在第5章中,我从Flask-Login实现了用户登录URL,其中可以包含next
查询字符串参数。
在下面,你可以看到我如何向主页和explore视图添加分页:
# app/routes.py: Followers association table
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
# ...
page = request.args.get('page', 1, type=int)
posts = current_user.followed_posts().paginate(
page, app.config['POSTS_PER_PAGE'], False)
return render_template('index.html', title='Home', form=form,
posts=posts.items)
@app.route('/explore')
@login_required
def explore():
page = request.args.get('page', 1, type=int)
posts = Post.query.order_by(Post.timestamp.desc()).paginate(
page, app.config['POSTS_PER_PAGE'], False)
return render_template("index.html", title='Explore', posts=posts.items)
进行了这些更改后,两条路由都会根据page
查询字符串参数或默认值1确定要显示的页码,然后使用paginate()
方法仅检索所需的结果页面。可以通过app.config
对象访问确定页面大小的POSTS_PER_PAGE
配置项。
注意这些更改有多容易,每次更改对代码的影响都很小。我试图在编写应用程序的任意部分时,不对其他部分的工作方式做任何假设,这使我能够编写模块化和健壮的应用程序,这些应用程序更易于扩展和测试,并且不太可能出现故障或出现错误。
继续尝试分页支持。首先,请确保拥有三个以上的博客文章。这在浏览页面中比较容易看到,该页面显示了所有用户的帖子。现在,你将只看到三个最新的帖子。如果要查看下三个,请在浏览器的地址栏中键入http://localhost:5000/explore?page=2
。
页面导航
下一个更改是在博客文章列表的底部添加链接,这些链接允许用户导航到下一页和/或上一页。 还记得我提到过paginate()
调用的返回值是Flask-SQLAlchemy的Pagination
类的对象吗? 到目前为止,我已经使用了该对象的items
属性,其中包含为选定页面检索的项目列表。 但是,此对象还有一些其他属性,在构建分页链接时非常有用:
has_next
:如果当前页面之后至少还有一页,则为truehas_prev
:如果当前页面之前至少还有一页,则为truenext_num
:下一页的页码prev_num
:上一页的页码
有了这四个元素,我可以生成下一页和上一页链接,并将它们传递给模板以进行渲染:
# app/routes.py: Next and previous page links.
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
# ...
page = request.args.get('page', 1, type=int)
posts = current_user.followed_posts().paginate(
page, app.config['POSTS_PER_PAGE'], False)
next_url = url_for('index', page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('index', page=posts.prev_num) \
if posts.has_prev else None
return render_template('index.html', title='Home', form=form,
posts=posts.items, next_url=next_url,
prev_url=prev_url)
@app.route('/explore')
@login_required
def explore():
page = request.args.get('page', 1, type=int)
posts = Post.query.order_by(Post.timestamp.desc()).paginate(
page, app.config['POSTS_PER_PAGE'], False)
next_url = url_for('explore', page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('explore', page=posts.prev_num) \
if posts.has_prev else None
return render_template("index.html", title='Explore', posts=posts.items,
next_url=next_url, prev_url=prev_url)
仅当在该方向上有一个页面时,这两个视图函数中的next_url
和prev_url
才会被设置为url_for()
返回的URL。 如果当前页面位于帖子集合的末端之一,则Pagination
对象的has_next
或has_prev
属性将为False
,在这种情况下,该方向上的链接将设置为None
。
我之前没有讨论过的url_for()
函数的一个有趣方面是,你可以向其添加任何关键字参数,并且如果这些参数的名称未在URL中直接引用,则Flask会将它们包含在URL中作为查询参数。
正在将分页链接设置为index.html
模板,因此现在让我们在帖子列表下方的页面上呈现它们:
<!-- app/templates/index.html: Render pagination links on the template. -->
...
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
{% if prev_url %}
<a href="{{ prev_url }}">Newer posts</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}">Older posts</a>
{% endif %}
...
此更改在索引和浏览页面上的帖子列表下方添加了两个链接。 第一个链接标记为“较新的帖子”,它指向上一页(请记住,我显示的帖子按最新顺序排列,因此第一页是内容最新的页面)。 第二个链接标记为“较旧的帖子”,指向帖子的下一页。 如果这两个链接中的任何一个为“无”,则通过条件将其从页面中省略。
用户个人资料页面中的分页
现在,index页面的更改已足够。 但是,用户个人资料页面中也有一个帖子列表,该列表仅显示该个人资料所有者的帖子。 为了保持一致,应该更改用户个人资料页面以匹配索引页面的分页样式。
首先,更新用户个人资料视图功能,该功能中仍然包含伪造的发布对象列表。
# app/routes.py: Pagination in the user profile view function.
@app.route('/user/<username>')
@login_required
def user(username):
user = User.query.filter_by(username=username).first_or_404()
page = request.args.get('page', 1, type=int)
posts = user.posts.order_by(Post.timestamp.desc()).paginate(
page, app.config['POSTS_PER_PAGE'], False)
next_url = url_for('user', username=user.username, page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('user', username=user.username, page=posts.prev_num) \
if posts.has_prev else None
form = EmptyForm()
return render_template('user.html', user=user, posts=posts.items,
next_url=next_url, prev_url=prev_url, form=form)
为了从用户那里获取帖子列表,我利用了一个事实,即user.posts
关系是由SQLAlchemy设置在用户模型中db.relationship()
定义的查询。 我接受此查询并添加了order_by()
子句,以便首先获取最新的帖子,然后完全按照对索引和浏览页面中的帖子所做的方式进行分页。 请注意,由url_for()
函数生成的分页链接需要额外的username
参数,因为它们指向的是用户个人资料页面,该页面具有该用户名作为URL的动态组成部分。
最后,对user.html
模板的更改与我在索引页上所做的更改相同:
<!-- app/templates/user.html: Pagination links in the user profile template. -->
...
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
{% if prev_url %}
<a href="{{ prev_url }}">Newer posts</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}">Older posts</a>
{% endif %}
在尝试使用分页功能之后,可以将POSTS_PER_PAGE
配置项设置为更合理的值:
# config.py: Posts per page configuration.
class Config(object):
# ...
POSTS_PER_PAGE = 25
(本章完毕)