CMS管理系统
一个能让用户发布内容的网站必须要有 CMS 管理系统,因为你不能确定用户发布的内容是否合法合规。pythonbbs 网站的 CMS 系统包括帖子管理、评论管理、前台用户管理、后台用户管理等。其中许多模块的实现技术大同小异,我们会选择有代表性的模块进行讲解,其他模块读者可自行完成。
CMS入口
首先在 blueprints/cms.py 中实现一个 CMS 首页的视图函数,代码如下。
...
@bp.get("")
def index():
return render_template("cms/index.html")
...
下面在前台页面导航条上判断是否是员工,如果是,则添加 CMS 入口链接。将 templates/front/base.html 的导航条部分代码修改如下。
...
{% if g.user.is_staff %}
<li class="nav-item">
<a href="{{ url_for('cms.index') }}" class="nav-link">管理系统</a>
</li>
{% endif %}
<li class="nav-item">
<a href="{{ url_for('user.profile', user_id=g.user.id) }}" class="nav-link">{{ g.user.username }}</a>
</li>
...
显示效果如图 9-40 所示。

单击右上角的 “管理系统”,即可进入 CMS 系统的首页。
权限管理
我们在定义权限时,分别定义了板块、帖子、评论、前台用户、后台用户的管理权限,并且针对这些权限分别定义了稽查、运营、管理员 3 个角色,这 3 个角色拥有的权限请参考表 9-1。另外,如果当前用户不是员工,则不允许访问 CMS 系统。我们先在 blueprints/cms.py 中添加 before_request 钩子函数,并在访问该蓝图下的视图函数前,先判断 user.is_staff 是否为 True,如果不是,则不允许访问,代码如下。
...
@bp.before_request
def cms_before_request():
if not hasattr(g, "user") or g.user.is_staff == False:
return redirect("/")
...
之所以在 cms 的蓝图中添加 before_request 钩子函数,而不是在 app 上添加,原因是这个判断只需针对 cms 的蓝图,而不需要进行全局判断。
我们再来针对角色做权限限制。先规定某个视图函数需要的权限,然后判断当前用户所属的角色有没有这个权限,如果有就能访问,否则就不能访问。我们可以使用装饰器来实现权限限制,在 pythonbbs/decorators.py 中添加以下代码。

上述代码中,我们定义了一个装饰器 permission_required,这个装饰器接收一个权限作为参数,在装饰器里面判断当前用户是否登录,并且是否拥有这个权限,如果有权限,则正常执行视图函数,否则抛出 403 错误。
如果用稽查用户访问 CMS 后台系统,依然可以看到,在左侧并没有权限的入口,如 “用户管理”、“员工管理” 等,CMS 管理系统首页如图 9-41 所示。
。图9-41 CMS管理系统首页 image::image-2025-01-22-16-51-20-300.png[]
这显然是不符合实际的。在侧边栏显示导航链接,应该先判断该用户是否有此项权限,有则渲染,无则不渲染。判断权限需要使用 PermissionEnum,因此在 blueprints/cms.py 中添加 context_processor 钩子函数,把 PermissionEnum 传给模板,代码如下。
...
from models.user import PermissionEnum
@bp.context_processor
def cms_context_processor():
return {"PermissionEnum": PermissionEnum}
...
在 templates/cms/base.html 中,将侧边栏中的导航渲染功能修改为如下代码。
...
<ul class="nav-sidebar">
<li class="unfold"><a href="{{ url_for('cms.index') }}">首页</a></li>
{% set user = g.user %}
{% if user.has_permission(PermissionEnum.POST) %}
<li class="nav-group post-manage"><a href="#">帖子管理</a></li>
{% endif %}
{% if user.has_permission(PermissionEnum.COMMENT) %}
<li class="comments-manage"><a href="#">评论管理</a></li>
{% endif %}
{% if user.has_permission(PermissionEnum.BOARD) %}
<li class="board-manage"><a href="#">板块管理</a></li>
{% endif %}
{% if user.has_permission(PermissionEnum.FRONT_USER) %}
<li class="nav-group user-manage"><a href="#">前台用户管理</a></li>
{% endif %}
{% if user.has_permission(PermissionEnum.CMS_USER) %}
<li class="nav-group UserModel-manage"><a href="#">员工管理</a></li>
{% endif %}
</ul>
...
此时我们再用稽查权限组下的用户访问 CMS 首页时,可以看到在侧边栏导航中只有 “帖子管理” 和 “评论管理” 了,如图 9-42 所示。

员工管理页面
用户模型的属性 is_staff 为 True 的用户为员工。管理员角色下的用户可以对其他角色下的用户进行管理,如取消员工资格、修改分组等。管理员之间无法修改对方信息。我们首先在 blueprints/cms.py 中添加 staff_list 视图函数,代码如下。
@bp.get("/staff/list")
@permission_required(PermissionEnum.CMS_USER)
def staff_list():
users = UserModel.query.filter_by(is_staff=True).all()
return render_template("cms/staff_list.html", users=users)
因为员工管理必须要有 PermissionEnum.CMS_USER 权限,所以要对视图函数添加 @permission_required(PermissionEnum.CMS_USER) 装饰器限制,包括后面的添加员工和编辑员工都属于员工管理,在编写相关视图时都要添加此装饰器限制。staff_list 视图函数中,首先提取了所有 is_staff 为 True 的用户,没有做分页处理,分页逻辑与首页帖子列表分页是一样的,读者可以自行添加。然后将 templates/cms/staff_list.html 的员工列表部分修改为如下代码。

上述代码中,我们循环员工列表,然后在表格的每列分别渲染对应的值。在渲染 “编辑” 按钮时,先判断该用户是否为管理员,如果是则不渲染,如果不是则渲染。在 templates/cms/base.html 中,在员工管理下添加超链接,实现单击即可跳转到员工管理页面的功能,代码如下。
...
{% if user.has_permission(PermissionEnum.CMS_USER) %}
<li class="nav-group UserModel-manage">
<a href="{{ url_for('cms.staff_list') }}">员工管理</a>
</li>
{% endif %}
...
访问员工管理页面,即可看到如图 9-43 所示的效果。

添加员工
在员工管理页面,有一个 “添加员工” 按钮,单击后可以跳转到 “添加员工” 页面。先在 blueprints/cms.py 中添加 add_staff 视图函数,代码如下。
@bp.route("/staff/add", methods=['GET', 'POST'])
@permission_required(PermissionEnum.CMS_USER)
def add_staff():
if request.method == "GET":
roles = RoleModel.query.all()
return render_template("cms/add_staff.html", roles=roles)
上述代码中,首先在 GET 请求中渲染了添加员工的模板。然后在员工管理页面的 “添加员工” 按钮上,添加跳转到 “添加员工” 页面的超链接,代码如下。
<a href="{{ url_for('cms.add_staff') }}" class="btn btn-primary mb-3">
添加员工
</a>
单击 “添加员工” 按钮,即可看到如图 9-44 所示的效果。

在图 9-44 中,角色只是静态数据,我们将角色部分代码修改成如下形式。
<div class="form-group">
<label>角色:</label>
{% for role in roles %}
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="role" id="inlineRadio{{ loop.index }}" value="{{ role.id }}">
<label class="form-check-label" for="inlineRadio{{ loop.index }}">{{ role.name }}</label>
</div>
{% endfor %}
</div>
我们再访问 “添加员工” 页面,角色部分就能渲染出真实数据了,如图 9-45 所示。

下面再在 forms 文件夹下创建 cms.py 文件,用来存放 cms 蓝图下的表单,并且创建 AddStaffForm 类,代码如下。
from .baseform import BaseForm
from wtforms import StringField, IntegerField
from wtforms.validators import Email, InputRequired
class AddStaffForm(BaseForm):
email = StringField(validators=[Email(message="请输入正确格式的邮箱!")])
role = IntegerField(validators=[InputRequired(message="请选择角色!")])
接着在 blueprints/cms.py 的 add_staff 视图函数中,完成在 POST 请求情况下的员工数据验证与保存,代码如下。

以后如果再添加员工,用户把注册网站时的邮箱发给管理员,即可选择角色添加为员工。
编辑员工
编辑员工要求只能编辑员工的分组、取消员工访问后台的权限,不能修改员工的邮箱、用户名、密码等信息。我们首先在 blueprints/cms.py 中添加一个名叫 edit_staff 的视图函数,并添加以下代码。
@bp.route("/staff/edit/<string:user_id>", methods=['GET', 'POST'])
@permission_required(PermissionEnum.CMS_USER)
def edit_staff(user_id):
user = UserModel.query.get(user_id)
if request.method == 'GET':
roles = RoleModel.query.all()
return render_template("cms/edit_staff.html", user=user, roles=roles)
下面在员工管理的员工列表部分,为 “编辑” 按钮添加编辑员工的超链接,代码如下。
{% if not user.has_permission(PermissionEnum.CMS_USER) %}
<a href="{{ url_for('cms.edit_staff', user_id=user.id) }}" class="btn btn-info btn-sm">编辑</a>
{% endif %}
重新访问员工管理页面,然后随机单击某个用户的 “编辑” 按钮,即可跳转到 “编辑员工” 页面,如图 9-46 所示。

接着在 forms/cms.py 中添加一个 EditStaffForm 的表单,代码如下。
class EditStaffForm(BaseForm):
is_staff = BooleanField(validators=[InputRequired(message="请选择是否为员工!")])
role = IntegerField(validators=[InputRequired(message="请选择分组!")])
完善 edit_staff 视图函数在 POST 请求情况下的逻辑处理,代码如下。

加载以上代码,在浏览器中就可以修改非管理员角色的用户信息了。
管理前台用户
前台用户的管理工作主要包括禁用和取消禁用,如果业务复杂一些,还可以实现对某个用户禁言一段时间的功能。但是无法对用户的信息进行编辑,如修改邮箱、密码等。另外,考虑到数据的价值,一般不会轻易删除用户。下面在 blueprints/cms.py 中添加 user_list 视图函数,用于返回用户列表。
@bp.route("/users")
@permission_required(PermissionEnum.FRONT_USER)
def user_list():
users = UserModel.query.filter_by(is_staff=False).all()
return render_template("cms/users.html", users=users)
再在 templates/cms/base.html 中,对侧边栏中的 “用户管理” 添加超链接,代码如下。
<li class="nav-group user-manage">
<a href="{{ url_for('cms.user_list') }}">用户管理</a>
</li>
访问 “用户管理” 页面,即可看到如图 9-47 所示的效果。
在 “用户管理” 页面的用户列表中,每行都有 “禁用” 按钮,这种情况比较适合使用 AJAX 方式来实现,首先在 blueprints/cms.py 中实现 active_user 视图函数,代码如下。
@bp.post("/users/active/<string:user_id>")
@permission_required(PermissionEnum.FRONT_USER)
def active_user(user_id):
is_active = request.form.get("is_active", type=int)
if is_active is None:
return restful.params_error(message="请传入is_active参数!")
user = UserModel.query.get(user_id)
user.is_active = bool(is_active)
db.session.commit()
return restful.ok()

因为用 AJAX 来交互数据,所以视图函数中需要用 restful 模块返回 JSON 格式的响应,在实现 JavaScript 代码前,我们有必要先了解 “禁用” 和 “取消禁用” 按钮在模板 templates/cms/users.html 中的代码结构,代码如下。
...
<td>
{% if user.is_active %}
<button class="btn btn-danger btn-sm active-btn" data-active="1" data-user-id="{{ user.id }}">禁用</button>
{% else %}
<button class="btn btn-info btn-sm active-btn" data-active="0" data-user-id="{{ user.id }}">取消禁用</button>
{% endif %}
</td>
...
上述代码中,把用户的 id 和用户当前是否可用的值,通过 data-*
属性绑定到了 “禁用” 和 “取消禁用” 按钮上,以方便后面在 JavaScript 代码中获取。接下来在 static/cms/js 文件夹下创建 users.js 文件,并且添加以下代码。
$(function () {
$(".active-btn").click(function (event) {
event.preventDefault();
var $this = $(this);
var is_active = parseInt($this.attr("data-active"));
var message = is_active ? "您确定要禁用此用户吗?" : "您确定要取消禁用此用户吗?";
var user_id = $this.attr("data-user-id");
var result = confirm(message);
if (!result) {
return;
}
var data = {
is_active: is_active ? 0 : 1
};
zlajax.post({
url: "/cms/users/active/" + user_id,
data: data
}).done(function () {
window.location.reload();
}).fail(function (error) {
alert(error.message);
});
});
});
上述代码中,因为所有的 “禁用” 和 “取消禁用” 按钮的类名都包含 active-btn,所以通过寻找类名为 active-btn 的元素来绑定单击事件。然后通过获取 is_active 来判断当前用户应 “禁用” 还是 “取消禁用”。使用 confirm 来判断是否真的要执行下一步的操作,效果如图 9-48 所示。
图9-48中,如果单击 OK 按钮,则发送 AJAX 请求更改用户状态。
在用户被禁用后,应该限制用户登录,在 blueprints/user.py 的 login 视图函数中,将代码修改如下。
...
if user and user.check_password(password):
if not user.is_active:
flash("该用户已被禁用!")
return redirect(url_for("user.login"))
session['user_id'] = user.id
if remember:
session.permanent = True
return redirect("/")
...

除此之外,在 decorators.py 模块下的 login_required 装饰器中,也应该添加对用户是否被禁用的验证。将 login_required 代码修改如下。
def inner(*args, **kwargs):
if not hasattr(g, "user"):
return redirect(url_for("user.login"))
elif not g.user.is_active:
flash("该用户已被禁用!")
return redirect(url_for("user.login"))
else:
return func(*args, **kwargs)
至此,在用户被禁用的状态下,被禁用的用户将无法访问所有需要登录权限的页面了。
帖子管理
帖子管理工作也仅仅是隐藏和显示,不能帮用户编辑帖子,隐藏帖子功能与禁用用户类似。我们首先实现帖子管理视图,用于渲染帖子列表。在 blueprints/cms.py 中添加 post_list 和 active_post 两个视图函数,代码如下。
...
@bp.get('/posts')
@permission_required(PermissionEnum.POST)
def post_list():
posts = PostModel.query.all()
return render_template("cms/posts.html", posts=posts)
@bp.post('/posts/active/<int:post_id>')
def active_post(post_id):
is_active = request.form.get("is_active", type=int)
if is_active is None:
return restful.params_error(message="请传入is_active参数!")
post = PostModel.query.get(post_id)
post.is_active = bool(is_active)
db.session.commit()
return restful.ok()
...
上述代码中,post_list 视图函数没有帖子分页的功能,读者可以参照首页帖子列表的分页功能进行实现。在浏览器中访问 “帖子管理” 页面,效果如图 9-49 所示。

在 static/cms/js 下创建一个 posts.js 文件,并添加以下代码。
$(function () {
$(".active-btn").click(function (event) {
event.preventDefault();
var $this = $(this);
var is_active = parseInt($this.attr("data-active"));
var message = is_active ? "您确定要隐藏此帖子吗?" : "您确定要显示此帖子吗?";
var post_id = $this.attr("data-post-id");
var result = confirm(message);
if (!result) {
return;
}
var data = {
is_active: is_active ? 0 : 1
};
console.log(data);
zlajax.post({
url: "/cms/posts/active/" + post_id,
data: data
}).done(function () {
window.location.reload();
}).fail(function (error) {
alert(error.message);
});
});
});
上述代码的实现逻辑与禁用用户是一样的,仅需要修改相关参数和 URL 即可。接下来在 template/cms/posts.html 的 head block 中,通过 script 标签加载 posts.js 文件,代码如下。
{% block head %}
<script src="{{ url_for('static', filename='cms/js/posts.js') }}"></script>
{% endblock %}
这样在浏览器中就可以通过单击 “隐藏” 或者 “显示” 按钮来对帖子进行操作了。当帖子被隐藏后,在首页渲染帖子列表时,应该过滤掉被隐藏的帖子。在 blueprints/front.py 的 index 视图函数中,将提取帖子的代码修改如下。
...
query_obj = PostModel.query.filter_by(is_active=True).order_by(PostModel.create_time.desc())
...
评论管理
评论管理的实现逻辑与帖子管理类似,读者可以自行完成。但是有一点需要注意,评论被禁用后,在帖子详情页应该过滤被禁用的评论。现在我们是通过 post.comments
获取帖子下的评论,由于 post.comments
是一个列表,无法使用 filter_by
方法进行过滤,所以我们将 models/post.py
中的 CommentModel
模型的 post
属性修改为如下代码。
post = db.relationship("PostModel", backref=db.backref('comments', order_by=create_time.desc(), lazy="dynamic"))
上述代码中,在 db.backref
函数中加入了 lazy="dynamic"
参数,这将使 post.comments
变成一个 AppenderQuery
对象,从而可以使用 filter
或 filter_by
方法进行过滤。我们在帖子详情页 templates/front/post_detail.html
中将评论列表部分修改成如下代码。
{% for comment in post.comments.filter_by(is_active=True) %}
<!-- 显示评论内容 -->
{% endfor %}
因为 post.comments
不是列表类型了,无法使用 length
过滤器,所以可以使用 AppenderQuery
的 count
方法,在首页模板 templates/front/index.html
和帖子详情模板 templates/front/post_detail.html
的显示评论数量的地方,将代码修改如下。
{{ post.comments.count() }}