发布帖子

发布帖子模块与注册模块有些类似,都是用来收集用户输入数据并发送到服务器。与注册模块不同的是,发布帖子需要选择帖子所属板块,帖子内容是富文本内容,下面分别进行讲解。

添加帖子相关模型

在实现发布帖子功能之前,先创建与帖子相关的模型,包括板块模型、帖子模型、评论模型。打开 models/post.py 文件,添加以下代码。

from exts import db
from datetime import datetime

# 板块模型
class BoardModel(db.Model):
    __tablename__ = 'board'
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(20), nullable=False)
    create_time = db.Column(db.DateTime, default=datetime.now)
    is_active = db.Column(db.Boolean, default=True)

# 帖子模型
class PostModel(db.Model):
    __tablename__ = 'post'
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text, nullable=False)
    create_time = db.Column(db.DateTime, default=datetime.now)
    read_count = db.Column(db.Integer, default=0)
    is_active = db.Column(db.Boolean, default=True)
    board_id = db.Column(db.Integer, db.ForeignKey("board.id"))
    author_id = db.Column(db.String(100), db.ForeignKey("user.id"), nullable=False)

    board = db.relationship("BoardModel", backref="posts")
    author = db.relationship("UserModel", backref='posts')

# 评论模型
class CommentModel(db.Model):
    __tablename__ = 'comment'
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    content = db.Column(db.Text, nullable=False)
    create_time = db.Column(db.DateTime, default=datetime.now)
    is_active = db.Column(db.Boolean, default=True)
    post_id = db.Column(db.Integer, db.ForeignKey("post.id"))
    author_id = db.Column(db.String(100), db.ForeignKey("user.id"), nullable=False)

    post = db.relationship("PostModel", backref='comments')
    author = db.relationship("UserModel", backref='comments')

模型需要被直接或者间接地导入 app.py 文件才能被检索到,在 blueprints/front.py 文件中导入以上模型,然后在 PyCharm 的 Terminal 中执行以下两条命令,即可将模型同步到数据库中。

$ flask db migrate -m "add post comment board model"
$ flask db upgrade

初始化板块数据

在网站上线前,应该先在数据库中添加一些板块数据,而且板块的数据一旦确定了也不会轻易变化。我们通过命令初始化板块数据,在 pythonbbs/comments.py 文件中添加以下代码。

def create_board():
    board_names = ['Python语法', 'web开发', '数据分析', '测试开发', '运维开发']
    for board_name in board_names:
        board = BoardModel(name=board_name)
        db.session.add(board)
    db.session.commit()
    click.echo("板块添加成功!")

下面再回到 app.py 文件中将 create_board 添加到命令中,代码如下。

...
app.cli.command("create-board")(commands.create_board)

打开 PyCharm 的 Terminal,执行 flask create-board 命令,即可完成板块数据的初始化。

渲染发布帖子模板

在渲染模板之前,先来看下发布帖子页面的效果,如图 9-29 所示。

image 2025 01 22 15 28 24 729
Figure 1. 图9-29 发布帖子页面效果

用户需要输入的数据包括标题、板块、内容,我们先来完成模板的渲染。在 blueprints/post.py 文件中,添加 public_post 视图函数,并且需要在模板中显示板块数据,所以在渲染模板时要把所有板块数据传给模板,代码如下。

@bp.route("/post/public", methods=['GET', 'POST'])
def public_post():
    if request.method == 'GET':
        boards = BoardModel.query.all()
        return render_template("front/public_post.html", boards=boards)
    else:
        pass

然后在 front/public_post.html 文件中,将板块数据循环渲染到板块的 select 标签下,代码如下。

...
<div class="form-group">
    <label>板块</label>
    <select name="board_id" class="form-control">
        {% for board in boards %}
            <option value="{{ board.id }}">{{ board.name }}</option>
        {% endfor %}
    </select>
</div>
...

在浏览器中访问 http://127.0.0.1:5000/post/public ,可以看到如图 9-30 所示的效果图。

image 2025 01 22 15 30 44 680
Figure 2. 图9-30 未加入富文本编辑器的发布帖子页面

现在模板虽然渲染了,但是还没有加入内容输入框,内容输入框需要加入富文本编辑器。下面讲解如何加入富文本编辑器。

使用wangEditor富文本编辑器

内容部分的输入框,应该具有类似 Word 软件的功能,如可以设置字体大小、颜色等,还可以插入图片,并且所见即所得,也就是编辑的时候是什么效果,以后在网页中展示的也是什么效果,这类编辑器叫作富文本编辑器。目前互联网上有许多免费开源的富文本编辑器,如百度官方出品的 UEditor、国外的 CKEditor,以及国内以王福朋为首的前端团队开发的 wangEditor。笔者尝试使用过多款富文本编辑器发现,wangEditor 简单易用、功能稳定,且有详细的中文文档,本书使用 wangEditor 作为富文本编辑器。

wangEditor 富文本编辑器的官方文档地址为 https://www.wangeditor.com/doc/

使用 wangEditor 分为 4 步,第 1 步在模板中引入 wangEditor.js 文件,第 2 步添加生成编辑器的占位标签,第 3 步初始化编辑器,第 4 步设置图片上传 URL,下面分别来实现。

  1. 引入wangEditor. js文件

    打开 templates/front/public_post.js 文件,然后实现 head 这个 block,并添加以下代码。

    ...
    {% block head %}
        <script src="https://cdn.jsdelivr.net/npm/wangeditor@latest/dist/wangEditor.min.js"></script>
    {% endblock %}
    ...

    上述代码中,我们使用 jsdelivr 服务器提供的 cdn 服务加载 wangEditor.min.js 脚本,读者如果要通过自己的服务器加载,可以把 wangEditor.min.js 保存下来,存放到自己的服务器加载即可。

  2. 添加生成编辑器的占位标签

    在需要生成 wangEditor 富文本编辑器的地方,添加一个占位标签,一般用 div 即可,为了后期方便寻找,在 div 标签上添加一个 id 属性,代码如下。

    ...
    <div class="form-group">
        <label>内容</label>
        <div id="editor"></div>
    </div>
    ...
  3. 初始化编辑器

    接下来将编辑器占位标签初始化成真正的编辑器,这里需要用到 JavaScript 代码,我们在 static/front/js 下创建一个 public_post.js 文件,然后添加以下代码。

    $(function () {
        var editor = new window.wangEditor("#editor");
        editor.create();
    });

    下面在 templates/front/public_post.html 的 head block 中加载此 js 文件,代码如下。

    ...
    {% block head %}
        <script src="https://cdn.jsdelivr.net/npm/wangeditor@latest/dist/wangEditor.min.js"></script>
        <script src="{{ url_for('static', filename='front/js/public_post.js') }}"></script>
    {% endblock %}
    ...

    执行以上代码后,访问 http://127.0.0.1:5000/post/public ,就可以看到 wangEditor 编辑器成功被渲染,效果如图 9-29 所示。

  4. 设置图片上传URL

如果不想在 wangEditor 中上传图片,这一步可以忽略,也不影响使用 wangEditor。这里添加图片上传的功能。首先在 blueprints/front.py 文件中添加上传图片的视图函数,代码如下。

image 2025 01 22 15 37 37 079

上述代码中,通过在视图函数 upload_image 的 request.files 中获取 image 参数来获取图片,这也意味着前端在上传图片时需要用 image 作为参数名。通过判断文件的后缀名来判断文件是否是图片,如果不是就返回 400 错误。这里没有用 restful 模块中的函数返回 JSON 数据,而是用 jsonify,这是因为 wangEditor 期望返回的结果为如下格式。

{
    "errno": 0,
    "data": [
        {
            "url": "url",
            "alt": "",
            "href": ""
        }
    ]
}

返回结果中的参数说明如下。

  • errorno:为 0 代表正常,非 0 代表异常。

  • data:图片上传后的信息数组,图片信息包括 URL、提示信息 alt 以及跳转链接 href。

为了防止黑客利用图片文件名攻击服务器,使用 werkzeug.utils.secure_filename 产生一个安全的文件名来保存图片,并且把文件存储在配置项 UPLOAD_IMAGE_PATH 指定的路径下,这个配置项可以自行设置。

在图片保存完成后,再使用 url_for 对 media.media_file 进行反转,用来获取图片的 URL。media.media_file 是新增的用于返回上传的文件的蓝图,为了后期方便,将上传的文件切换到 Nginx 服务器上部署。下面创建一个 URL(以 /media 开头的蓝图),在开发阶段都会使用这个蓝图返回图片文件。首先在 blueprints 下创建 media.py 文件,然后输入以下代码。

from flask import Blueprint, current_app
import os

bp = Blueprint("media", __name__, url_prefix="/media")

@bp.get("/<path:filename>")
def media_file(filename):
    return os.path.join(current_app.config.get("UPLOAD_IMAGE_PATH"), filename)

接下来在 app.py 文件中对 media 蓝图进行注册,代码如下。

from blueprints.media import bp as media_bp
...
app.register_blueprint(media_bp)
...

图片上传的视图函数写完后,再来到 static/front/js/public_post.js 脚本中,添加设置上传图片 URL 的代码,代码如下。

var editor = new window.wangEditor("#editor");
editor.config.uploadImgServer = "/upload/image";
editor.config.uploadFileName = "image";
editor.create();

在浏览器中重新加载发布帖子的页面,然后在 wangEditor 富文本编辑器中单击 “图片” 按钮,可以看到上传图片的功能已经添加,如图 9-31 所示。

image 2025 01 22 15 42 59 365
Figure 3. 图9-31 wangEditor添加上传图片功能

但是现在还不能使用上传图片功能,因为 wangEditor 使用 POST 方法上传图片,而我们的项目对非 GET 请求做了 CSRF 防御,必须要提交 csrf_token,这样上传图片才能保证安全。对此可以对 upload_image 视图函数关闭 CSRF 防御,在 exts.py 文件中添加以下代码。

from flask_wtf import CSRFProtect
...
csrf = CSRFProtect()

下面再回到 app.py 文件中,将原来 CSRFProtect(app) 的代码修改为以下代码。

...
from exts import csrf
...
csrf.init_app(app)

这样做是因为在 blueprints/post.py 文件中也需要使用 csrf 对象,如此可避免循环导入。然后在 upload_image 函数定义中加上 @csrf.exempt 装饰器,代码如下。

@bp.post("/upload/image")
@csrf.exempt
def upload_image():
...

这样就针对 upload_image 视图函数关闭了 CSRF 防御,我们在 wangEditor 中重新上传图片,可以看到上传图片功能已经可以正常使用了。

未登录限制

现在发布帖子的页面在用户未登录的情况下可以直接访问,这样是不严谨的。如果用户没有登录发布帖子页面,应该重定向到登录页面。在用户访问网站后,为了方便获取当前用户的信息,需要添加 before_request 钩子函数。在 pythonbbs 项目的根路径下创建一个 hooks.py 文件,添加以下代码。

from flask import session, g
from models.user import UserModel

def bbs_before_request():
    if "user_id" in session:
        user_id = session.get("user_id")
        try:
            user = UserModel.query.get(user_id)
            setattr(g, "user", user)
        except Exception:
            pass

在 pythonbbs/app.py 文件中,将 bbs_before_request 钩子函数添加进去,代码如下。

import hooks
...
# 添加钩子函数
app.before_request(hooks.bbs_before_request)

这样用户在登录的情况下访问本网站,会在全局对象 g 上面添加一个 user 属性,以后通过 g.user 即可获取到当前登录用户的信息,如果 g 没有 user 属性,说明此用户没有登录。

我们把未登录限制做成装饰器,添加到需要登录才能访问的视图函数中。在 pythonbbs 根目录下创建一个 decorators.py 文件,然后添加以下代码。

from functools import wraps
from flask import redirect, url_for, g

def login_required(func):
    @wraps(func)
    def inner(*args, **kwargs):
        if not hasattr(g, "user"):
            return redirect(url_for("user.login"))
        else:
            return func(*args, **kwargs)
    return inner

上述代码中,添加了一个 login_required 装饰器函数,login_required 接收一个函数作为参数,通过全局对象 g 有无 user 属性,判断用户是否登录,若未登录就重定向到登录页面,否则就按照正常流程执行被装饰的函数。然后在 blueprints/post.py 文件中的 public_post 和 upload_image 上添加 login_required 装饰器,修改后代码如下。

from decorators import login_required
...
@bp.route("/post/public", methods=['GET', 'POST'])
@login_required
def public_post():
...

@bp.post("/upload/image")
@csrf.exempt
@login_required
def upload_image():
...

上述代码中,视图函数上的装饰器是有顺序的,在有多个装饰器的情况下,执行顺序是从里到外,开发者要充分考虑请求到达服务器后执行的过程,合理分配装饰器的位置。

此后用户在未登录的情况下重新访问发布帖子页面时,即会被重定向到登录页面了。

服务端实现发帖功能

在 blueprints/post.py 文件的 public_post 视图函数中,GET 请求是返回模板,POST 请求则是发布帖子。先添加发布帖子的表单,用来验证客户端上传的数据是否正确。在 pythonbbs/forms 下创建一个 post.py 文件,然后添加以下代码。

from .baseform import BaseForm
from wtforms import StringField, IntegerField
from wtforms.validators import InputRequired, Length

class PublicPostForm(BaseForm):
    title = StringField(validators=[Length(min=2, max=100, message='请输入正确长度的标题!')])
    content = StringField(validators=[Length(min=2, message="请输入正确长度的内容!")])
    board_id = IntegerField(validators=[InputRequired(message='请输入板块 id!')])

上述代码中,添加了 PublicPostForm 类,在其中定义了 title、content、board_id 这 3 个字段。然后把 PublicPostForm 导入 blueprints/front.py 文件,并在 public_post 视图函数中添加如下代码。

@bp.route("/post/public", methods=['GET', 'POST'])
@login_required
def public_post():
    if request.method == 'GET':
        boards = BoardModel.query.all()
        return render_template("front/public_post.html", boards=boards)
    else:
        form = PublicPostForm(request.form)
        if form.validate():
            title = form.title.data
            content = form.content.data
            board_id = form.board_id.data
            post = PostModel(title=title, content=content, board_id=board_id, author=g.user)
            db.session.add(post)
            db.session.commit()
            return restful.ok()
        else:
            message = form.messages[0]
            return restful.params_error(message=message)

上述代码中,因为在 before_request 钩子函数中已经把 user 绑定到 g 对象上,所以可以直接通过 g.user 获取用户信息,并在初始化 PostModel 时赋值给 author 属性。

使用AJAX发布帖子

现在的帖子内容部分是通过 wangEditor 进行编辑的,只有通过 wangEditor 提供的 JavaScript 接口才能获取用户输入的内容,因此发布帖子的请求需要用 AJAX 来实现。wangEditor 是通过 editor.txt.html() 方法获取内容的,在 static/front/js/public_post.js 脚本中添加以下代码。

$(function () {
    ...
    // 提交按钮单击事件
    $("#submit-btn").click(function (event) {
        event.preventDefault();

        var title = $("input[name='title']").val();
        var board_id = $("select[name='board_id']").val();
        var content = editor.txt.html();

        zlajax.post({
            url: "/post/public",
            data: { title, board_id, content }
        }).done(function (data) {
            setTimeout(function () {
                window.location = "/";
            }, 2000);
        }).fail(function (error) {
            alert(error.message);
        });
    });
});

上述代码中,我们绑定了提交按钮事件,然后分别从 HTML 标签中获取用户输入的 title、board_id 以及 content 数据,再用 zlajax.post 方法把数据发送到服务器上。在请求成功后跳转到首页,请求失败则弹出提示对话框。