第11章:博客文章-Flask Web 开发(第2版)

本章将实现 Flasky 应用的主要功能:允许用户查看并发表博文。你将学习一些新知识:复用模板、分页及使用富文本。

博文的提交和显示

为了支持博文,需要创建一个新的模型。

# 示例 11-1. app/models.py: 博文(Post)模型
class Post(db.Model):
    __tablename__ = 'posts'
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'))

class User(UserMixin, db.Model):
    # ...
    posts = db.relationship('Post', backref='author', lazy='dynamic')

在应用程序的首页,需要显示一个表单,以便用户发表博文。博文表单类定义如下:

# 示例 11-2. app/main/forms.py: 博文表单类
class PostForm(FlaskForm):
    body = TextAreaField("此刻所想", validators=[DataRequired()])
    submit = SubmitField('提交')

index() 视图函数处理表单并将已存在的博文列表传递给模板。

# 示例 11-3. app/main/views.py: 带有博文的首页
@main.route('/', methods=['GET', 'POST'])
def index():
    form = PostForm()
    if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit():
        post = Post(body=form.body.data,
                    author=current_user._get_current_object())
        db.session.add(post)
        db.session.commit()
        return redirect(url_for('.index'))
    posts = Post.query.order_by(Post.timestamp.desc()).all()
    return render_template('index.html', form=form, posts=posts)

【wrdll 注】示例 11-3 中,Permission.WRITE_ARTICLES 是不存在的,之前创建的权限类只有 WRITE。练习本示例时,请注意修改。

注意:

  • posts = Post.query.order_by(Post.timestamp.desc()).all() 表示,按时间的倒序将所有已存在的博文取出

  • current_user._get_current_object(),和其它上下文变量一样,current_user 是个当前线程代理对象,它看上去好像是普通对象,实质是一个经过包装的对象。所以需要通过_get_current_object()方法获取到真实的 User 模型对象,再传给数据库。因为数据库需要的是真实的对象,而不是一个代理对象。

【wrdll 注】此用法在第10章的示例10-8已出现,只是作者到此处才指出。

接下来,需要对首页的模板进行修改:

<!-- 示例 11-4. app/templates/index.html: 带博文的首页模板 -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
...
<div>
    {% if current_user.can(Permission.WRITE_ARTICLES) %}
    {{ wtf.quick_form(form) }}
    {% endif %}
</div>
<ul class="posts">
    {% for post in posts %}
    <li class="post">
        <div class="profile-thumbnail">
            <a href="{{ url_for('.user', username=post.author.username) }}">
                <img class="img-rounded profile-thumbnail"
                    src="{{ post.author.gravatar(size=40) }}">
            </a>
        </div>
        <div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
        <div class="post-author">
            <a href="{{ url_for('.user', username=post.author.username) }}">
                {{ post.author.username }}
            </a>
        </div>
        <div class="post-body">{{ post.body }}</div>
    </li>
    {% endfor %}
</ul>
...

【wrdll 注】示例 11-4 中,Permission.WRITE_ARTICLES 是不存在的,之前创建的权限类只有 WRITE。练习本示例时,请注意修改。

模板中的博文列表是通过无序列表来展示的。为了获得更好的展示效果,需要自定义 CSS 样式。该 CSS 样式存放在 git 仓库里。

如果你从 Github 克隆了示例代码,可以使用 git checkout 11a 签出本示例。

在用户资料页面显示博文

用户资料页面可以显示该用户发表的博文。

# 示例 11-5. app/main/views.py: 带博文列表的用户资料路由
@main.route('/user/<username>')
def user(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        abort(404)
    posts = user.posts.order_by(Post.timestamp.desc()).all()
    return render_template('user.html', user=user, posts=posts)

用户发表的博文是通过 User.posts 这个关联关系获得的,它的操作和 query 对象类似,因此也能使用 order_by()all() 等。

index.html 一样,user.html 模板也需要一些 <ul> 标签来显示这些博文列表。请不要从 index.html 拷贝代码或手动重写这段代码,在多处书写相同的代码是一个很不好的方法。通过 Jinja2 的 include 可以优雅的处理这个问题。这段显示博文列表的代码片段可以放在单独的 _posts.html 文件中,然后在 index.htmluser.html 中使用 include 将其包含进来。示例11-6展示了如何在 user.html 使用 include

<!-- 示例 11-6. app/templates/user.html: 带博文列表的用户资料模板 -->
...
<h3>{{ user.username }} 发表的博文</h3>
{% include '_posts.html' %}
...

如果你从 Github 克隆了示例代码,可以使用 git checkout 11b 签出本示例。

对博文列表进行分页

随着网站的发展,博文的数量也会变的越来越多,在首页和用户资料页面将完整的博文列表进行显示,将会变得非常慢,甚至根本无法显示。大页面在 Web 浏览器中生成、下载和渲染需要更长的时间,因此随着页面变大,用户体验会下降。解决方案是对数据进行分页并对其进行分块渲染。

创建博文的模拟数据

为了实现博文的分页功能,有必要建立一个包含大量数据的测试数据库。手动添加大量博文数据是一件费时而乏味的事情,这种事情更适合通过自动化工具来完成。有许多 Python 包都能用来生成模拟数据,其中一个就是 Faker。可以通过 pip 来安装这个包:

(venv) $ pip install faker

严格来说, Faker 包并不是应用程序的依赖包,因为它只是应用程序开发期间所需要的一个工具。为了将开发环境和生产环境所依赖的包进行分离,我们需要将 requirements.txt 替换成 requirements 目录,并在该目录里对不同的环境生成对应的依赖清单文件。比如,开发环境的依赖清单,可以放在 requirements/dev.txt 文件中;生产环境的依赖清单,可以放在 requirements/prod.txt 文件中。同时,在两个环境中,会有许多相同的依赖包,这些在各个环境都需要的依赖清单,可以放在 requirements/common.txt 文件中。在 dev.txtprod.txt 中,只需加上 -r common.txt 即可将通用依赖清单附加到当前环境的依赖清单里。示例11-7展示了 requirements/dev.txt 的内容。

示例 11-7. requirements/dev.txt: 开发环境的依赖清单

-r common.txt
faker==0.7.18

示例11-8显示了一个新增的模块,它包含两个函数,分别用于为用户和博文生成虚拟数据。

# 示例 11-8. app/fake.py: 为用户和博文生成虚拟数据
from random import randint
from sqlalchemy.exc import IntegrityError
from faker import Faker
from . import db
from .models import User, Post

def users(count=100):
    fake = Faker()
    i = 0
    while i < count:
        u = User(email=fake.email(),
                 username=fake.user_name(),
                 password='password',
                 confirmed=True,
                 name=fake.name(),
                 location=fake.city(),
                 about_me=fake.text(),
                 member_since=fake.past_date())
        db.session.add(u)
        try:
            db.session.commit()
            i += 1
        except IntegrityError:
            db.session.rollback()

def posts(count=100):
    fake = Faker()
    user_count = User.query.count()
    for i in range(count):
        u = User.query.offset(randint(0, user_count - 1)).first()
        p = Post(body=fake.text(),
                timestamp=fake.past_date(),
                author=u)
        db.session.add(p)
    db.session.commit()

Faker 包提供了随机生成姓名、Email、句子等数据的多种生成器。在本项目中,用户名和 Email 都要求值是唯一的,但是 Faker 只是随机生成数据,不保证数据的唯一性。万一生成了重复的数据,在提交到数据库时,会引发 IntegrityError 异常。所以,需要在代码里捕获这个异常,一旦捕获到这个异常,对事务进行回滚。

博文需要和一个随机用户进行关联,所以在代码中使用了 offset() 过滤器。这个过滤器接收一个整型参数。在 offset() 后,使用 first() ,如此,便拿到了一个随机用户。

如果你从 Github 克隆了示例代码,可以使用 git checkout 11c 签出本示例。为保证安装了所有依赖,请在签出后执行 pip -r requirements/dev.txt

在 Python Shell 中调用这两个函数,可以方便地生成模拟数据。

(venv) $ flask shell
>>> from app import fake
>>> fake.users(100)
>>> fake.posts(100)

运行之后,你将在页面上看到很长的博文列表,每个博文都关联着一个随机的用户。

在页面里渲染

示例11-9所示,为了支持分页而对首页路由进行修改。

# 示例 11-9. app/main/views.py: 对博文列表进行分页
@main.route('/', methods=['GET', 'POST'])
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
        error_out=False)
    posts = pagination.items
    return render_template('index.html', form=form, posts=posts,
                           pagination=pagination)

当前页码是通过 URL 的 page 参数传递的,所以可以在 Flask 中使用 request.args 来获取。在获取该参数的时候,加了 type=int,用于将参数值转换成整数。

之前,使用 query 对象的 all() 来获取所有数据。现在改用 paginate() 来分页获取。paginate() 有三个参数,第一个参数是当前页码,我们使用通过 URL 传递过来的 page 参数;第二个参数 per_page 用于设定每页显示多少条记录,我们通过配置文件中的 FLASKY_POSTS_PER_PAGE 来指定;第三个参数 error_out 用于设置在没有记录的分页上,是否引发404错误,该参数的默认值是 True,我们将其指定为 False,即,当分页没有记录时,不引发404错误。

别忘了在 config.py 里设置 FLASKY_POSTS_PER_PAGE 的值。

增加一个分页小部件

paginate() 的返回值是一个由 Flask-SQLAlchemy 定义的 Pagination 类的实例对象。这个对象有许多用于在模板中生成翻页链接的属性和方法。所以,需要将该对象传递给模板。

下表总结了 Pagination 对象的属性和方法。

属性或方法 说明
items 当前分页的记录
query 被分页的源查询
page 当前分页的页码
prev_num 当前分页的上一页的页码
next_num 当前分页的下一页的页码
has_prev 当前分页是否有上一页
has_next 当前分页是否有下一页
pages 当前查询的总页数
per_page 每个分页中包含的记录条数
total 当前查询的总记录数
items 当前分页的记录
iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2) 一个迭代器,用于返回页码序列以显示在分页小部件中。left_edge 表示在列表开始处保留的页码数;left_current 表示当前页码左侧保留的页码数;right_current 表示当前页面右侧保留的页码数;right_edge 表示在列表结尾处保留的页码数。假设,当前页码是50,一共有100页。按照默认值,它将返回以下页码:1, 2, None, 48, 49, 50, 51, 52, 52, 54, 55, None, 99, 100。其中的 None 是分隔符。
prev() 表示上一页的Pagination 对象
next() 表示下一页的Pagination 对象

使用 Booststrap 的 pagination CSS 类,可以对分页进行美化。示例11-10展示了其实现。注意,这里使用了 Jinja2 的宏,以便复用这个分页小部件。

<!--示例 11-10. app/templates/_macros.html: 分页模板的宏定义-->
{% macro pagination_widget(pagination, endpoint) %}
<ul class="pagination">
    <li{% if not pagination.has_prev %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_prev %}{{ url_for(endpoint,
            page = pagination.page - 1, **kwargs) }}{% else %}#{% endif %}">
            &laquo;
        </a>
    </li>
    {% for p in pagination.iter_pages() %}
        {% if p %}
            {% if p == pagination.page %}
            <li class="active">
                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
            </li>
            {% else %}
            <li>
            <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
            </li>
            {% endif %}
        {% else %}
        <li class="disabled"><a href="#">&hellip;</a></li>
        {% endif %}
    {% endfor %}
    <li{% if not pagination.has_next %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_next %}{{ url_for(endpoint,
            page = pagination.page + 1, **kwargs) }}{% else %}#{% endif %}">
            &raquo;
        </a>
    </li>
</ul>
{% endmacro %}

将这个分页小部件添加到 index.htmluser.html 模板中。

<!-- 示例 11-11. app/templates/index.html: 博文列表的分页导航 -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}
...
{% include '_posts.html' %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.index') }}
</div>
{% endif %}

如果你从 Github 克隆了示例代码,可以使用 git checkout 11d 签出本示例。

使用 Markdown 和 Flask-PageDown 实现富文本格式的博文

纯文本博文对于简短的信息和状态更新已足够,但想要编写较长文章的用户会发现缺少对格式的支持。在本节中,发表博文的文本框将升级为支持 Markdown 语法,并以富文本格式进行预览。

要实现这一功能,需要一些新的包:

  • PageDown:一个用 JavaScript 实现的,在客户端将 Markdown 转换成 HTML 的转换器。
  • Flask-PageDown:对 PageDown 进行封装的 Flask 扩展。它将 PageDown 和 Flask-WTF 整合在一起。
  • Markdown:一个用 Python 实现的,在服务端将 Markdown 转换成 HTML 的转换器。
  • Bleach:一个用 Python 实现的 HTML 清理器。

这些 Python 包可以通过 pip 进行安装:

(venv) $ pip install flask-pagedown markdown bleach

使用 Flask-PageDown

Flask-PageDown 扩展,提供了一个和 WTForms 的 TextAreaField 具有相同接口的 PageDownField 类。在使用它之前,需要先进行初始化。

# 示例 11-12. app/__init__.py: Flask-PageDown 初始化
from flask_pagedown import PageDown
# ...
pagedown = PageDown()
# ...
def create_app(config_name):
    # ...
    pagedown.init_app(app)
    # ...

要将首页里的多行文本框变成支持富文本的 Markdown 编辑器,需要将 PostForm 表单类里的 body 字段修改为 PageDownField,如示例11-13所示。

# 示例 11-13. app/main/forms.py: 支持 Markdown 的博文表单类
from flask_pagedown.fields import PageDownField

class PostForm(FlaskForm):
    body = PageDownField("此刻所想", validators=[Required()])
    submit = SubmitField('提交')

【wrdll 注】validators=[Required()] 中的 Required() 应改为 DataRequired()

Markdown 预览需要借助 PageDown 库来生成,所以必须将其添加到模板。Flask-PageDown 提供了一个模板宏来快速达成这一目标。这个宏将需要的所有资源文件,通过 CDN 来加载到当前页面。

<!-- Example 11-14. app/templates/index.html: Flask-PageDown 模板定义-->
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

如果你从 Github 克隆了示例代码,可以使用 git checkout 11e 签出本示例。签出后,请执行 pip install -r requirements/dev.txt

在服务端处理富文本

表单提交后,只有原始的 Markdown 文本会通过 POST 发送到服务器,使用 Flask-PageDown 生成的、用于预览的 HTML 不会发送。将 HTML 发送到服务器会有安全隐患。为杜绝潜在的安全隐患,只将 Markdown 文本发送到服务器,然后在服务器将其转换成 HTML。之后,再使用 HTML 清理器,来保证转换后的 HTML 代码中,只有指定的若干标签。

【wrdll 注】Markdown 里也可以直接写 HTML 标签。请自行查阅 Markdown 的文档。——这也是进行 HTML 清理的原因之一

通过 Markdown 转换器生成的 HTML 代码可以放在 _posts.html 模板中。但是,每次渲染页面的时候,都要调用一次转换器。这对服务器性能是一个影响。为了解决这个问题,可以在发表或修改博文的时候,调用一次转换器,并将其转换后的 HTML 保存到数据库中。之后,在模板里直接读取这个转换后的 HTML 即可。原始的 Markdown 文本也要保存在数据库中,因为博文可能还要进行修改。

# 示例 11-15. app/models.py: 在 Post 模型中处理 Markdown
from markdown import markdown
import bleach

class Post(db.Model):
    # ...
    body_html = db.Column(db.Text)
    # ...
    @staticmethod
    def on_changed_body(target, value, oldvalue, initiator):
        allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
                        'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
                        'h1', 'h2', 'h3', 'p']
        target.body_html = bleach.linkify(bleach.clean(
            markdown(value, output_format='html'),
            tags=allowed_tags, strip=True))

db.event.listen(Post.body, 'set', Post.on_changed_body)

【wrdll 注】如果需要更高级的功能(比如:表格转换、TOC支持、代码高亮等),需要对 Python 的 Markdown 包进行配置,有些高级功能还需要额外的插件。请自行搜索相关知识,或通过本站录制的视频教程进行学习。

on_changed_body() 注册为 body 字段的 SQLAlchemy set 事件的监听器,意思是,当 body 字段的值发生改变时, on_changed_body() 会被自动调用。该函数自动将 Markdown 转换后的 HTML 代码存储在 body_html 字段里。

整个转换过程分三步:首先,markdown() 函数将 Markdown 文本转换成初始版本的 HTML 代码。然后把这个 HTML 代码传给 clearn() 函数,该函数会对 HTML 代码里的标签进行清理,所有未在 allowed_tags 定义的标签都将被清除(strip=True)。最后,调用 linkify() 函数,给所有 URL 自动加上超链接(<a> 标签)。

最后一处修改是将模板文件里的 post.body 变成 post.body_html

<!-- 示例 11-16. app/templates/_posts.html: 在模板中使用 body_html 显示博文内容 -->
...
<div class="post-body">
    {% if post.body_html %}
        {{ post.body_html | safe }}
    {% else %}
        {{ post.body }}
    {% endif %}
</div>
...

safe 是 Jinja2 里的过滤器,用于在网页上显示原始 HTML 代码。如果没加该过滤器, post.body_html 中的 HTML 将被转义。

如果你从 Github 克隆了示例代码,可以使用 git checkout 11f 签出本示例。签出后,请执行 pip install -r requirements/dev.txt

博文的固定链接

用户可能想把博文通过社交工具分享给朋友。基于此,每篇博文都应该有唯一的 URL。示例11-17展示了支持固定链接的路由和视图函数。

# 示例 11-17. app/main/views.py: 为博文启用固定链接
@main.route('/post/<int:id>')
def post(id):
    post = Post.query.get_or_404(id)
    return render_template('post.html', posts=[post])

有些应用里(比如 wrdll.com)使用更具可读性的信息来代替基于 id,生成固定链接。通常使用 slug 来代替 id。

接下来,给模板中的博文列表加上固定链接。由于 index.htmluser.html 都将 _posts.html 包含到了各自模板中,所以只需要修改 _posts.html 一个文件即可。

<!-- 示例 11-18. app/templates/_posts.html: 为博文添加固定链接-->
<ul class="posts">
    {% for post in posts %}
    <li class="post">
        ...
        <div class="post-content">
            ...
            <div class="post-footer">
                <a href="{{ url_for('.post', id=post.id) }}">
                    <span class="label label-default">固定链接</span>
                </a>
            </div>
        </div>
    </li>
    {% endfor %}
</ul>

下面再为 post() 视图函数创建对应的模板文件 app/template/post.html。它也包含了 _posts.html

<!-- 示例 11-19. app/templates/post.html: 固定链接模板 -->
{% extends "base.html" %}

{% block title %}Flasky - 博文{% endblock %}

{% block page_content %}
{% include '_posts.html' %}
{% endblock %}

如果你从 Github 克隆了示例代码,可以使用 git checkout 11g 签出本示例。

修改博文

博文的最后一个功能是,允许用户修改自己发表的博文。其模板如下:

<!-- 示例 11-20. app/templates/edit_post.html: 修改博文模板 -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - 修改博文{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>修改博文</h1>
</div>
<div>
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

对应的路由和视图函数如下:

# 示例 11-21. app/main/views.py: 修改博文的路由和视图函数
@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
    post = Post.query.get_or_404(id)
    if current_user != post.author and \
            not current_user.can(Permission.ADMIN):
        abort(403)
    form = PostForm()
    if form.validate_on_submit():
        post.body = form.body.data
        db.session.add(post)
        db.session.commit()
        flash('博文修改成功.')
        return redirect(url_for('.post', id=post.id))
    form.body.data = post.body
    return render_template('edit_post.html', form=form)

这个视图函数限定了,只有博文作者或管理员才能修改博文。当一个非管理员用户尝试修改其他用户的博文时,将会引发403错误。和首页一样,这个路由也使用 PostForm 表单类。

最后,给每篇博文加上“修改”链接:

<!-- 示例 11-22. app/templates/_posts.html: 添加“修改”链接 -->
<ul class="posts">
    {% for post in posts %}
    <li class="post">
        ...
        <div class="post-content">
            ...
            <div class="post-footer">
                ...
                {% if current_user == post.author %}
                <a href="{{ url_for('.edit', id=post.id) }}">
                    <span class="label label-primary">修改</span>
                </a>
                {% elif current_user.is_administrator() %}
                <a href="{{ url_for('.edit', id=post.id) }}">
                    <span class="label label-danger">修改 [管理员]</span>
                </a>
                {% endif %}
            </div>
        </div>
    </li>
    {% endfor %}
</ul>

如果你从 Github 克隆了示例代码,可以使用 git checkout 11h 签出本示例。

本文并非《Flask Web Development(2nd edition)》的全文翻译。而只是在阅读该书时,对自认为重点的内容进行记录以及思考。由于水平有限,本文所述内容难免出现不足或错误。请通过购买正版图书来进行更系统地学习,原书地址。本站将依照本系列文章录制视频教程,届时本文未作记录的部分以及项目实际开发中所需要的知识都会在视频教程中进行补充。

flask Flask Web Development(2nd edition) 2018-03-27 21:23 3107867