09.分页

在第八章我已经做了几个数据库更改,以支持在社交网络的“粉丝”机制。

在本章中,应用将开始接受来自用户的动态更新,并将其发布到网站首页和个人主页。因为用户的动态可以非常多,在同一个页面显示全部动态显然不是合适的选择,所以我们还要对数据列表进行分页。






发布用户动态

要发布用户动态,首页需要有一个表单,用户可以在其中键入新动态。我们先创建一个表单类,从功能上看,这个表单类应该是 main 模块的功能,所以我们创建 app\main\forms.py

# app\main\forms.py

from flask_wtf import FlaskForm
from wtforms import (
    SubmitField,
    TextAreaField
)
from wtforms.validators import (
    DataRequired, 
    Length
)

class PostForm(FlaskForm):
    post = TextAreaField(
        'Say something', 
        validators=[DataRequired(), Length(min=1, max=140)]
    )
    submit = SubmitField('Submit')

然后,我将该表单添加到网站首页的 HTML 模板中:

{% extends "base.html" %}

{% extends "base.html" %}

{% block content %}
  <h1>Hello, {{ current_user.username }}!</h1>

  {% if form %}
    <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>
  {% endif %}

  {% for post in posts %}
    {% include '_post.html' %}
  {% endfor %}
{% endblock %}

最后将表单处理逻辑添加到视图函数中:

# app\main\routes.py

from flask_login import current_user, login_required
from app.models import Post
from app.main.forms import PostForm

@main_routes.route('/', methods=['GET', 'POST'])
@main_routes.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('main.index'))

    posts = current_user.followed_posts().all()
    return render_template(
        'index.html', 
        title='Home Page', 
        form=form,
        posts=posts
    )

我们来一个个地解读该视图函数的变更:

  • 导入 PostPostForm
  • 关联到 index 视图函数的两个路由都能接受 POST 请求,以便视图函数处理接收的表单数据
  • 处理表单的逻辑会为 post 表插入一条新的数据
  • 模板新增接受 form 对象,以便渲染文本输入框

在继续之前,我想讨论下与 Web 表单处理相关的内容。 请注意,在处理表单数据后,将通过发送重定向(redirect)到主页来结束请求。我可以不进行重定向,并允许函数继续向下进入模板渲染部分,因为这已经是主页视图函数了。

那么,为什么要重定向呢? 通过重定向来响应 Web 表单提交产生的 POST 请求是一种标准做法。这有助于解决在浏览器中执行刷新命令带来的麻烦。因为当你点击刷新键时,所有的网页浏览器都会重新发出最后的请求。

如果带有表单提交的 POST 请求返回一个常规的响应,那么刷新将重新提交表单。 因为这不是预期的行为,所以浏览器会要求用户确认重复的提交,但是大多数用户却很难理解浏览器询问的内容。不过,如果一个 POST 请求被重定向响应,浏览器现在被指示发送 GET 请求来获取重定向中指定的页面,所以现在最后一个请求不再是 POST 请求了, 刷新命令就能以更可预测的方式工作。

这个简单的技巧叫做 Post/Redirect/Get 模式。 它避免了用户在提交网页表单后无意中刷新页面时插入重复的动态。






展示用户动态

上一章 我们在 User 模型中实现了了 followed_posts() 方法,它可以返回给定用户所关注的用户动态的查询结果集。 现在我们来使用它展示用户动态:

# app\main\routes.py

from flask_login import current_user, login_required
from app.models import Post

@main_routes.route('/', methods=['GET', 'POST'])
@main_routes.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() 才会触发它的执行,返回值是包含所有结果的列表。

需要注意的是,在该路由函数我们使用了 @login_required 装饰器,只有登录用户才能访问该页面,我希望在非登录访问该页面时候,会跳转到 login 页面并给出一个提示语,这需要我们修改 flask-login 的配置。

# app\__init__.py

from flask_login import LoginManager

login = LoginManager()
login.login_view = "auth.login"
login.login_message = "Please log in to access this page."

def create_app(config):
    app = Flask(__name__)
    login.init_app(app)

login.login_view = "auth.login" 定义了当未登录用户访问有 login_required 限制的路由时会被跳转到的路由,这里它会被跳转到 url_for('auth.login')

login.login_message 则定义了登录的提示信息,他会用 flash 方法来处理。






更容易地发现和关注用户

应用开发到这里,功能上还要一个不完善的地方,就是用户现在根本没法查看有哪些其它用户存在。

我将会创建一个新的“发现”页面。该页面看起来像是主页,实际上它也会和主页共用模板,但是却不是只显示已关注用户的动态,而是展示所有用户的全部动态。新增的发现视图函数如下:

# app\main\routes.py

@main_routes.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 表单时崩溃,我将添加一个条件,只在传入 form 参数后才会呈现该表单:

# app\templates\index.html

{% if form %}
  <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>
{% endif %}

发现页面也需要添加到导航栏中:

# app\templates\base.html

<a href="{{ url_for('main.explore') }}">Explore</a>

最后我们修改渲染用户动态的 _post.html 子模板。之前该模板是在个人主页模板中被调用,它独立于其他模板,因此也可以被其他模板调用。我现在要做一个小小的改进,将用户动态作者的用户名显示为一个链接:

# app\templates\_post.html

<table>
  <tr valign="top">
    <td>
      <img src="{{ post.author.avatar(36) }}">
    </td>
    <td>
      <a href="{{ url_for('auth.user', username=post.author.username) }}">
        {{ post.author.username }}
      </a>      
      says:<br>{{ post.body }}
    </td>
  </tr>
</table>

现在在主页和发现页中也可以使用这个子模板来渲染用户动态:

# app\templates\index.html

{% for post in posts %}
  {% include '_post.html' %}
{% endfor %}

子模板需要传入一个名为 post 的变量,才能正常工作。该变量是上层模板中通过循环产生的。

通过这些细小的变更,应用的用户体验得到了大大的提升。现在,用户可以访问发现页来查看陌生用户的动态,并通过这些用户动态来关注用户,而需要的操作仅仅是点击用户名跳转到其个人主页并点击关注链接。






用户动态的分页

应用进一步完善了,但是在主页显示所有用户动态迟早会出问题。如果一个用户有成千上万条关注的用户动态时,会发生什么?你可以想象得到,管理这么大的用户动态列表将会变得相当缓慢和低效。

为了解决这个问题,我会将用户动态进行分页。这意味着一开始显示的只是所有用户动态的一部分,并提供链接来访问其余的用户动态。因为 Flask-SQLAlchemypaginate() 方法原生就支持分页,所以我们不需额外安装其他的库。

paginate 方法,需要输入三个参数来调用它:

  • 从 1 开始的页码
  • 每页的数据量
  • 错误处理布尔标记,如果是 True,当请求范围超出已知范围时自动引发 404 错误。如果是 False,则会返回一个空列表。

paginate 方法返回一个 Pagination 的实例。其 items 属性是请求内容的数据列表。Pagination 实例还有一些其他用途,在之后讨论。

一个分页的示例:

>>> user.followed_posts().paginate(1, 20, False).items

基于低耦合的原则,我首先会把跟分页相关的参数写到配置文件 config.py 里:

# config.py

class Config(object):
    # ...
    POSTS_PER_PAGE = 3

POSTS_PER_PAGE 参数定义的是每一页显示多少条动态,为了便于测试我们先把它定义为 3。让这类参数在配置文件里定义的好处是日后如果我要修改它,只需修改配置文件,而不是修改每一个文件里的硬编码。

接下来,我需要决定如何将页码并入到应用 URL 中。 一个相当常见的方法是使用查询字符串参数来指定一个可选的页码,如果没有给出则默认为页面 1。 以下是一些示例网址:

要访问查询字符串中给出的参数,我可以使用 Flask 的 request.args 对象。 前面用 Flask-Login 实现了用户登录的时候我们曾使用过。

# app\main\routes.py

@main_routes.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts_pre_page = current_app.config['POSTS_PER_PAGE']
    posts = current_user.followed_posts() \
        .paginate(page, posts_pre_page, False).items

    return render_template(
        'index.html', 
        title='Home Page', 
        form=form,
        posts=posts
    )

@main_routes.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    posts_pre_page = current_app.config['POSTS_PER_PAGE']
    posts = Post.query \
        .order_by(Post.timestamp.desc()) \
        .paginate(page, posts_pre_page, False).items
        
    return render_template(
        'index.html', 
        title='Explore', 
        posts=posts
    )

加上分页功能后,这两个路由决定了要显示的页码,可以从 page 查询字符串参数,如果 URL 不带参数,则默认值 1。然后使用 paginate() 方法来查询指定页码范围的结果。定义页面数据列表大小的 POSTS_PER_PAGE 配置项是通过 current_app.config 对象中获取的。

来尝试下分页功能。首先确保你有三条以上的用户动态。你现在只会看到最近的三条用户动态。如果你想看接下来的三条,请在浏览器的地址栏中输入:http://localhost:5000/explore?page=2






分页导航

接下来的工作就是在用户动态列表的底部添加链接,允许用户导航到下一页或上一页。

还记得我曾提到过 paginate() 的返回是 Pagination 类的实例吗?到目前为止,我已经使用了此对象的 items 属性,其中包含为所选页面检索的用户动态列表。 但是这个分页对象还有一些其他的属性在构建分页链接时很有用:

  • has_next:当前页之后存在后续页面时为真
  • has_prev:当前页之前存在前置页面时为真
  • next_num:下一页的页码
  • prev_num:上一页的页码

有了这四个元素,就可以生成上一页和下一页的链接并将其传入模板以渲染:

# app\main\routes.py

@main_routes.route('/', methods=['GET', 'POST'])
@main_routes.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts_pre_page = current_app.config['POSTS_PER_PAGE']
    posts = current_user.followed_posts() \
        .paginate(page, posts_pre_page, False)
    next_url = url_for('main.index', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('main.index', page=posts.prev_num) \
        if posts.has_prev else None

    return render_template(
        'index.html', 
        title='Home Page', 
        form=form,
        posts=posts.items,
        next_url=next_url,
        prev_url=prev_url
    )

@main_routes.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    posts_pre_page = current_app.config['POSTS_PER_PAGE']
    posts = Post.query \
        .order_by(Post.timestamp.desc()) \
        .paginate(page, posts_pre_page, False)
    next_url = url_for('main.explore', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('main.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_urlprev_url 只有在该方向上存在一个页面时,才会被设置为由 url_for() 返回的 URL。 如果当前页面位于用户动态集合的末尾或者开头,那么 Pagination 实例的 has_nexthas_prev 属性将为 False,在这种情况下,将设置该方向的链接为 None

url_for() 函数的一个有趣的地方是,你可以添加任何关键字参数,如果这些参数的名字没有直接在 URL 中匹配使用,那么 Flask 将它们设置为 URL 的查询字符串参数。

比如 url_for('main.explore', page=2)page 参数并非对应路由函数的装饰器所定义的 URL 参数,它就会被解释为: /explore?page=2

现在让我们把它们渲染在 index.html 模板上,就在用户动态列表的正下方:

# app\templates\index.html

{% if prev_url %}
  <a href="{{ prev_url }}">Newer posts</a>
{% endif %}
{% if next_url %}
  <a href="{{ next_url }}">Older posts</a>
{% endif %}

主页和发现页都添加了分页链接。第一个链接标记为 “Newer posts”,并指向前一页(请记住,我显示的用户动态按时间的倒序来排序,所以第一页是最新的内容)。 第二个链接标记为 “Older posts”,并指向下一页的帖子。 如果这两个链接中的任何一个都是 None,则通过条件过滤将其从页面中省略。






个人主页中的分页

主页分页已经完成,但是,个人主页中也有一个用户动态列表,其中只显示个人主页拥有者的动态。为了保持一致,个人主页也应该实现分页,以匹配主页的分页样式。

更新个人主页视图函数:

# app\auth\routes.py

@auth_routes.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_per_page = current_app.config['POSTS_PER_PAGE']
    posts = user.posts.order_by(Post.timestamp.desc()).paginate(
        page, 
        posts_per_page, 
        False
    )
    next_url = url_for('auth.user', username=user.username, page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('auth.user', username=user.username, page=posts.prev_num) \
        if posts.has_prev else None

    return render_template(
        'auth/user.html', 
        user=user, 
        posts=posts.items,
        next_url=next_url,
        prev_url=prev_url
    )

为了得到用户的动态列表,我利用了 User 模型中定义的 user.posts 一对多关系查询。 我执行该查询并添加一个 order_by() 子句,以得到最新的用户动态,然后完全按照我对主页和发现页面中的用户动态所做的那样进行分页。

请注意,由 url_for() 函数生成的分页链接需要额外的 username 参数,因为它们指向个人主页,个人主页依赖用户名作为 URL 的动态组件。

最后,对 user.html 模板作与在主页上相同的更改:

# app\templates\auth\user.html

{% 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 配置项设置为更合理的值:

class Config(object):
    # ...
    POSTS_PER_PAGE = 25






本文源码:https://github.com/SingleDiego/Flask-Tutorial-Source-Code/tree/SingleDiego-patch-09

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,718评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,683评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,207评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,755评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,862评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,050评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,136评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,882评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,330评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,651评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,789评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,477评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,135评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,864评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,099评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,598评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,697评论 2 351

推荐阅读更多精彩内容