美文网首页
Django suit admin 将json格式的数据拆分成表

Django suit admin 将json格式的数据拆分成表

作者: Sixpence_ | 来源:发表于2020-12-05 15:24 被阅读0次

    需求背景

    有时候为了配置的灵活性、应用未来的需求变化、控制单张表的字段数,避免字段过多,会把一些字段设置是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)
    
        # 重写__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 字符串配置,难看、难配、易出错,表单的形式就更人性化、更容易用啦。

    相关文章

      网友评论

          本文标题:Django suit admin 将json格式的数据拆分成表

          本文链接:https://www.haomeiwen.com/subject/evfgwktx.html