Published on

动态博客的后台定制

自定义 Flask-Admin 的表单控件

Reading Time

5 min read

搭建动态博客的初衷就是想随时随地,只要一个浏览器,就能更新博客。那么就需要一个后台来管理文章,包含文章编辑器,和各种表单控件。

编辑器

先来解决文本编辑器的问题,CKEditor 功能强大,但只是一个富文本编辑器。对于已经习惯 Markdown 写作的我来说,只管写,排版渲染就交给浏览器去做。找了很多内嵌 Markdown 编辑器,既要外观匹配,还要最好带预览功能。最终我选择了 Simple MDE

使用方法非常简单,引入 CSS, Javascript 文件后,只需要一句话就搞定了:

<script>
  var simplemde = new SimpleMDE({ element: document.getElementById('MyID') })
</script>

具体到 Flask-Admin,只需重载admin/model/edit.htmladmin/model/create.html模板文件,在其中加入对应 HTML 代码,然后在ModelView中分别指定create_templateedit_template就行了。外观如下:

我已经事先把 Flask-Admin 的基模板给换成了 bootstrap4。这个编辑器全屏模式下支持分栏预览,非常惊艳。

Tag 与 Category 输入框

TagCategoryPost的两个属性,其中一个是多对多关系,另一个是一对多关系。Flask-Admin 原生支持这两种类型的属性输入框,但有以下不足:

  • 基于 Select2 3.x,不支持自由输入的选择框(tags)。
  • 无法动态添加不存在的项到数据库中。

针对以上两点开始我们的定制。首先将要加载自由输入的选择框打上 HTML 标记,在ModelView中:

form_widget_args = {
    'tags': {'data-role': 'select2-free'},
    'category': {'data-role': 'select2-free'},
}

重载edit.htmlcreate.html,引入 select2 4.0.x 的文件,以及以下 Javascript 代码:

$('[data-role=select2-free]').each(function(){ $(this).select2({tags: true}); });

现在可以自由输入了,还需要动态添加。查看 Flask-Admin 的源码,对应这两种域的表单分别定义为QuerySelectFieldQuerySelectMultiField,它们被 hardcode 在AdminModelConverter._model_select_field里面,而AdminModelConverterModelView中被指定。所以我们要重载QuerySelectField的行为,则需要继承AdminModelConverter,重载下面的_model_select_field方法,再将其加载到我们自定义的ModelView就可以了,示意图如下:

为了自定一个SelectField,重载了三个类,真是大费周章。在重载的QuerySelectField里,我们需要实现以下逻辑:

  • 先寻找匹配的 model 对象,并绑定到form.data里(未重载之前的行为)
  • 剩下的未匹配的选择项,为它们创建 model 对象,并绑定到form.data里。
class AutoAddSelectField(QuerySelectField):
    def __init__(self, model_factory, *args, **kwargs):
        super(AutoAddSelectField, self).__init__(*args, **kwargs)
        self.model_factory = model_factory

    def _get_data(self):
        if self._formdata is not None:
            for pk, obj in self._get_object_list():
                if pk == self._formdata:
                    self._set_data(obj)
                    break
            else:
                obj = self.model_factory(self._formdata)
                self._set_data(obj)
        return self._data

    def _set_data(self, data):
        self._data = data
        self._formdata = None

    data = property(_get_data, _set_data)

    def pre_validate(self, form):
        pass

我们要在初始化时传入 model 的创建方法,并取消了有效性检查。QuerySelectMultiField也大同小异了。最终效果如下:

美中不足

动态添加做好了,那么删除呢?想像一下这个使用场景,你修改文章,把一个标签删除了,这个标签已经没有任何文章使用,那你肯定不希望它再出现在标签列表里吧?SQLAlchemy 中有cascade属性,用来指定parent改变时child的行为,但不符合我们的要求,因为我们要的是一对多和多对多关系中「多」的一方变化时另一方的行为。于是我们需要监听before_flush信号,检查当前session中的对象并做对应处理。

def auto_delete_orphans(attr):
    target_class = attr.parent.class_

    @sa.event.listens_for(sa.orm.Session, 'after_flush')
    def delete_orphan_listener(session, ctx):
        session.query(target_class).filter(~attr.any())\
                                   .delete(synchronize_session=False)

auto_delete_orphans(Tag.posts)
auto_delete_orphans(Category.posts)
Share: