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 所示。

image 2025 01 22 16 48 19 317
Figure 1. 图9-40 有CMS入口的前台页面

单击右上角的 “管理系统”,即可进入 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 中添加以下代码。

image 2025 01 22 16 50 37 291

上述代码中,我们定义了一个装饰器 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 所示。

image 2025 01 22 16 53 39 199
Figure 2. 图9-42 有权限管理的CMS首页

员工管理页面

用户模型的属性 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 的员工列表部分修改为如下代码。

image 2025 01 22 16 55 26 277

上述代码中,我们循环员工列表,然后在表格的每列分别渲染对应的值。在渲染 “编辑” 按钮时,先判断该用户是否为管理员,如果是则不渲染,如果不是则渲染。在 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 所示的效果。

image 2025 01 22 16 56 50 609
Figure 3. 图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 所示的效果。

image 2025 01 22 16 58 53 443
Figure 4. 图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 所示。

image 2025 01 22 17 00 15 991
Figure 5. 图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 请求情况下的员工数据验证与保存,代码如下。

image 2025 01 22 17 01 47 146

以后如果再添加员工,用户把注册网站时的邮箱发给管理员,即可选择角色添加为员工。

编辑员工

编辑员工要求只能编辑员工的分组、取消员工访问后台的权限,不能修改员工的邮箱、用户名、密码等信息。我们首先在 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 所示。

image 2025 01 22 17 04 09 219
Figure 6. 图9-46 “编辑员工”页面

接着在 forms/cms.py 中添加一个 EditStaffForm 的表单,代码如下。

class EditStaffForm(BaseForm):
    is_staff = BooleanField(validators=[InputRequired(message="请选择是否为员工!")])
    role = IntegerField(validators=[InputRequired(message="请选择分组!")])

完善 edit_staff 视图函数在 POST 请求情况下的逻辑处理,代码如下。

image 2025 01 22 17 05 18 860

加载以上代码,在浏览器中就可以修改非管理员角色的用户信息了。

管理前台用户

前台用户的管理工作主要包括禁用和取消禁用,如果业务复杂一些,还可以实现对某个用户禁言一段时间的功能。但是无法对用户的信息进行编辑,如修改邮箱、密码等。另外,考虑到数据的价值,一般不会轻易删除用户。下面在 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()
image 2025 01 22 17 08 40 707
Figure 7. 图9-47 “用户管理” 页面效果

因为用 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("/")
...
image 2025 01 22 17 12 42 826
Figure 8. 图9-48 确认是否取消禁用

除此之外,在 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 所示。

image 2025 01 22 17 15 56 406
Figure 9. 图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 对象,从而可以使用 filterfilter_by 方法进行过滤。我们在帖子详情页 templates/front/post_detail.html 中将评论列表部分修改成如下代码。

{% for comment in post.comments.filter_by(is_active=True) %}
    <!-- 显示评论内容 -->
{% endfor %}

因为 post.comments 不是列表类型了,无法使用 length 过滤器,所以可以使用 AppenderQuerycount 方法,在首页模板 templates/front/index.html 和帖子详情模板 templates/front/post_detail.html 的显示评论数量的地方,将代码修改如下。

{{ post.comments.count() }}

板块管理

板块管理使用到的知识点与前面的用户管理类似,这里不再重复讲解,读者可以自行完成。板块管理要实现的功能如下。

(1)“禁用” 和 “取消禁用”:禁用板块后,在首页中不应该再渲染该板块,并且该板块下的帖子不能访问,所以在帖子详情中要做好判断。

(2)编辑板块:仅可以对板块的名称进行修改。