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)

    # 重写__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 字符串配置,难看、难配、易出错,表单的形式就更人性化、更容易用啦。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容