vlambda博客
学习文章列表

Django suit admin 将json格式的数据拆分成表单形式展示

需求背景

有时候为了配置的灵活性、应用未来的需求变化、控制单张表的字段数,避免字段过多,会把一些字段设置是Json格式。像下面这样:

json_field.png

这样的好处是,后面如果突然需要加多一个字段,就可以直接在加到这个json里面,既不用修改数据表,也不用修改程序,只要通知前端我在json里面加了一个字段就好了,对于未来字段设置不确定或者随时改变的情况是非常方便。


但有一个不好的地方就是,配置起来很麻烦,后台可能是给到一些不懂技术的人用,他们看到这么个东西就很一脸蒙蔽,甚至有时候技术自己可能出配着配着一不小心少了个引号,少个了逗号导致json格式错误,这就会导致程序解析json错误然后出现异常,影响了线上应用。所以这样的后台对于懂技术不懂技术的人来说都是一个挑战。

所以我就想着把这个json,拆解成一个表单的形式,像下面这样:

form.png

这样看起来是不是就直观很多,不管是谁来用这个后台都能很轻易的上手。


实现原理

原理也很简单。

加载时:
第1步:把 json 解析出来,把 json 里的每个字段当做是 model 的单个独立的字段去处理,赋值到对象上;
第2步:自定义一个表单 form , 把这些从 json 解析出来的字段也显示到管理后台上。

写入时:
第1步:接收自定义表单传过来的数据后进来验证(看需求要不要做一些数据验证)
第2步:把数据封装成 json 后再赋值到 model 上保存该 json 的字段,然后写入数据库保存。

实现思路就这么几步,很简单,只是编码过程中会存在一些细节的问题,下面通过编码来把上面的步骤走一遍。

编码实现

解析 json 并赋值到 model 对象,当成普通字段处理

要处理刚从数据库读出来的数据,只需要重写一下 Model 类的一个类方法from_db

下面这一段是源码的from_db方法

    @classmethod
    def from_db(cls, db, field_names, values):
        if len(values) != len(cls._meta.concrete_fields):
            values_iter = iter(values)
            values = [
                next(values_iter) if f.attname in field_names else DEFERRED
                for f in cls._meta.concrete_fields
            ]
        new = cls(*values)
        new._state.adding = False
        new._state.db = db
        return new

那我们要做的就是在自己的 Model 中重写这个方法,然后先调用父类的from_db方法完成数据的加载

    @classmethod
    def from_db(cls, db, field_names, values):
        new = super().from_db(db, field_names, values)
        # todo 在这里添加上 json 解析逻辑
        return new

下面是我实现的把 json 解析成 model 对象字段的代码,我封装成一个类,哪个 model 需要解析 json 的直接继承这个类就好了

class JsonTransToField(models.Model):
    @staticmethod
    def get_image_name(image_url):
        """
        解析图片url,去掉url前缀,保留图片名称
        如:http://test.xxx.com/media/test.png --> test.png
        """

        image_url_prefix = f'{MEDIA_DOMAIN}/media/'
        image_name = image_url.replace(image_url_prefix, '')
        return image_name


    @staticmethod
    def dict_to_field(instance, data, prefix):
        """
        prefix: 字段名前缀。
        核心逻辑。把 json 解析到成 model 对象的普通字段。
        如:{'test': '123'}  --> instance.test = '123'
        """

        for key, value in data.items():
            # 递归解析 json 里的子 json
            if isinstance(value, dict):
                JsonTransToField.dict_to_field(instance, value, f'{prefix}___{key}')
                continue

            # 解析图片 url 成 ImageField。v.find('alipay-xx.oss') 这段是因为图片都是存放在阿里去上,用来判断该字段是否图片字段
            if isinstance(value, str) and value.find('alipay-xx.oss') > -1:
                setattr(instance, f'{prefix}___{key}', ImageFieldFile(instance, models.ImageField(name=f'{prefix}___{key}'),
                                                                    JsonTransToField.get_image_name(value)))
            else:
                setattr(instance, f'{prefix}___{key}', value)


    @classmethod
    def from_db(cls, db, field_names, values):
        """
        捕获 json 解析异常,避免发生异常的时候会影响线上应用。
        但管理后台该表单会没有数据,因为异常后没有把 json 里的数据解析到 instance上
        """

        new = super().from_db(db, field_names, values)
        try:
            # 迭代instance的字段,如果数据是以 { 开头的说明是 json,进行解析操作
            fields = new.__dict__.copy()
            for field, value in fields.items():
                if isinstance(value, str) and value.startswith('{'):
                    data = json.loads(value)
                    JsonTransToField.dict_to_field(new, data, field)
        except Exception:
            LogUtil.error("解析life json异常", traceback.format_exc())
        return new


    class Meta:
        abstract = True

有 2 个地方说明一下:

  1. 代码中的{prefix}_{key}是设置到 instance 上的字段名,prefix 指的是 json 字段的字段名,后面接 3个下划线(因为2个下划线是外键的读取方法,避免冲突),然后接上 json 是的 key。如:params={"test":"123"} --> params___test

  2. setattr(instance, f'{prefix}{key}', ImageFieldFile(instance, models.ImageField(name=f'{prefix}{key}'), JsonTransToField.get_image_name(value))) 这段代码是为了把图片解析成 ImageFieldFile 类型,这样图片在后台显示的样式就是上图广告ICON的样子,这样方便图片的上传设置,否则会以图片链接的形式显示在后台。

自定义 form 表单

上面我们已经把 json 解析成对象的普通字段了,现在要做的就是把这些字段像 model 定义好的字段一样显示在后台。
这里先假设数据表里有一个这样的 json 字段,方便理解:

params = {"task""""reward""""adv": {"icon""""title""""subtitle"""}, "link""""link_type""TO_APPLET_PAGE""app_id""""path"""}

我们定义一个 form 如下:

class CustomForm(ModelForm):
    params___task = CharField(label='任务内容', max_length=20, required=False)
    params___reward = CharField(label='任务奖励说明', max_length=20, required=False)
    params___adv___icon = ImageField(label='广告ICON', required=False)
    params___adv___title = CharField(label='任务标题', max_length=20, required=False)
    params___adv___subtitle = CharField(label='任务副标题', max_length=20, required=False)
    params___link = CharField(label='链接', max_length=150, required=False)
    params___link_type = TypedChoiceField(label='链接类型', choices=[('TO_APPLET_PAGE''小程序'), ('TO_H5''H5'), ('TO_APPLET_LOCAL_PAGE''本地页面')], required=False)
    params___app_id = CharField(label='appId', required=False)
    params___path = CharField(label='path', required=False)

    # 把 model 定义的字段定义也加上
    # ················

    # 重写__init__方法。初始化 form 的时候,把 instance 中解析出来的 json 字段添加到 form 的 initial 中,否则后台不会显示出来
    def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
                 initial=None, error_class=ErrorList, label_suffix=None,
                 empty_permitted=False, instance=None, use_required_attribute=None,
                 renderer=None)
:

        super(CustomForm, self).__init__(data, files, auto_id, prefix,
                                              initial, error_class, label_suffix,
                                              empty_permitted, instance, use_required_attribute,
                                              renderer)
        if instance is not None:
            for k, field in instance.__dict__.items():
                if k.find('___') > - 1:
                    self.initial[k] = getattr(instance, k)

    def save_image(self, instance, file, name):
        """
        封装成ImageFieldFile,并保存上传的图片资源
        """

        # 没有上传图片是 'None'
        if str(file) == 'None':
            return ''
        image = models.ImageField(upload_to='you store folder/', name=name)
        image_file = ImageFieldFile(instance, image, str(file))
        image_file._file = file
        # 发生更改的图片是 InMemoryUploadedFile 类型,这种情况才需要保存图片资源
        if isinstance(file, InMemoryUploadedFile):
            image_file.save(image_file.name, image_file.file, save=False)
        return image_file


    # 核心逻辑。提交表单,把自定义表单字段组装成json
    def clean(self):
        # data: 存放 dict 数据
        data = {}
        for key, value in self.fields.items():
            if key.find('___') > - 1:
                # 这里一个 for 循环是为了递归的封装 dict.
                # 如果 params___adv___icon、params___title  --> {"params": {"title": ""}, "adv": {"icon": ""}}
                parents = key.split('___')
                # d: 当前进行封装的 dict 
                d = {}
                p = data
                for parent in parents[:-1]:
                    d = p.setdefault(parent, {})
                    p = d

                # 图片资源则保存图片或上传到云存储,然后包装成完整的访问 url
                # parent[-1] 就是是里面一层的字段名。如:params___adv___icon --> ['params', 'adv', 'icon']
                if isinstance(value, ImageField):
                    image_file = self.save_image(self.instance, self.cleaned_data.get(key), key)
                    if image_file:
                        image_file = f'{MEDIA_DOMAIN}/media/{image_file.name}'
                    d[parents[-1]] = image_file
                else:
                    d[parents[-1]] = self.cleaned_data.get(key, '')

       # 最后把封装好的 dict 转成 json 赋值到对应的字段
        for k, v in data.items():
            setattr(self.instance, k, json.dumps(v, ensure_ascii=False))


    class Meta:
        model = You Model
        fields = '__all__'

这里主要是重写了 ModelForm 的 clean 方法,在里面将特定数据封装成 json 然后再保存到 model 中。代码功能都带注释了。
clean 里面的逻辑最好是自己跟着实现一遍,调试一下,直观的看封装过程会更容易理解,单看代码可能会有点难理解。

替换自带 form

最后一步,把上面写好的 form , 添加到admin中,还可以加一个tab,把自定义的表单单独出来一个 tab ,避免很多字段揉杂在一起显得乱。

class CustomAdmin(admin.ModelAdmin):
# ············
    form = CustomForm
    fieldsets = [
        (None, {
            'classes': ('suit-tab''suit-tab-general'),
            'fields': []  # 这里放基础的字段
        }),
        ('跳转链接配置', {
            'classes': ('suit-tab''suit-tab-link'),
            'fields': ['params___task''params___reward''params___adv___icon''params___adv___title',
                       'params___adv___subtitle''params___link''params___link_type''params___app_id''params___path']  # 这里放自定义表单的字段
        })]
    suit_form_tabs = [('general''基础'), ('link''跳转链接配置')]
# ············

最后效果图如下:

final.png

头部多了一个可切换的 tab,也可以设置多个tab,在 fieldsets 列表里面追加就行了。这个可以按自己的喜欢或者逻辑重新排版字段。


到此,我们的需求就完成啦,对比一开始的通过 json 字符串配置,难看、难配、易出错,表单的形式就更人性化、更容易用啦。