Django 管理页面:通过多个模型选择而不是原始文本自定义 ID 的字典 (JSONField)

Bor*_*jaX 4 python django customization django-admin django-jsonfield

我有一个模型,其中一个字段是postgres.fields.JSONField

将要存储在那里的 Json 是引用数据库中其他项目(可能的关系/属性)的 ID 变量字典。

请允许我更具体:

基本上,我正在尝试创建一个折扣系统,其中某些折扣适用于某些产品。JSON 字段包含了解哪些产品可以获得折扣的约束。

例如:

  • 如果我想对属于“饮料”类别的所有产品应用 50% 的折扣,并且“饮料”类别5在数据库中具有 id ,则折扣记录将如下所示:

    discount_type='percent'
    discount='0.5'
    filter_by={
        'category': [5]
    }
    
    Run Code Online (Sandbox Code Playgroud)
  • 如果我想$ 20 off适用于所有产品的“饮料”类别由制造,比方说,可口可乐,该filter_by词典将如下所示:

    discount_type='fixed amount'
    discount='20'
    filter_by={
        'category': [5],
        'manufacturer': [2]   # Assuming coca-cola is the Manufacturer 
                              # with id==2 in the 'Manufacturers'
                              # table of the database (NOTE: this is 
                              # needed since CocaCola manufactures
                              # products besides "Beverages")
    }
    
    Run Code Online (Sandbox Code Playgroud)
  • 如果我想对特定产品(比方说产品id3)应用 25% 的折扣,字典将如下所示:

    discount_type='percent'
    discount='0.25'
    filter_by={
        'id': [3]
    }
    
    Run Code Online (Sandbox Code Playgroud)

这个想法似乎对我的需要足够灵活,我很高兴(到目前为止)。


现在,问题是如何在模型的 Django 管理区域中输入这些值Discount

正如预期的那样,filter_by字典呈现为文本字段,最初如下所示:

在此处输入图片说明

If I want to add fields to it, I need to write the exact JSON of what I want... Which means that if I want to apply a discount to the "Beverages" category, I need to go figure out which ID that category has in the database, and then manually type {"category": [5]}, while being extremely careful when typing the ', the :, make sure that I don't miss a ] or a [...

Thaaaat... well, that is not very helpful...

Since I am only going to be filtering by a few fields (category, manufacturer, product...) which are actually lists of IDs of other elements of the database, I would like to show a big MultiSelect box per thingy I can filter for, so I can see a user friendly list of all the elements I can filter by, select a few, and then, when I click on "Create discount", I would get the filter_by dictionary (I'm still far from worrying about how to generate the dictionary, since I don't even know how to properly render the Admin form).

Something like what Django Admin automatically did for my Products' categories:

在此处输入图片说明

That is really, really, nice: One product can belong to several categories. For that, Django renders, side by side, two <select multiple boxes, with the available categories, and the categories that the product already belongs to... I can add/remove categories through the stroke of a mouse... Really, really nice. But Django can do that because it knows that the categories are a ManyToMany relation in the Product model.

class Product(models.Model):
    parent = models.ForeignKey('self', null=True, blank=True)
    manufacturer = models.ForeignKey('Manufacturer')
    categories = models.ManyToManyField('Category',
                                         related_name='products', blank=True)
Run Code Online (Sandbox Code Playgroud)

The problem with the Discount model is that there is no ManyToMany field to category, manufacturer or product. Poor Django doesn't know that a Discount is related to all those things: It only knows there's a Json field.

I would really like to be able to show a bunch of those <select> in the Django Area listing all the possible filters (Category, Manufacturer, ID...) that can be stored in the filter_by dictionary (one entry with the double <select> for Category showing all the available categories in the database, one entry for Manufacturer, showing all the available manufacturers... etcetera). But I really, really don't know how to do that.

I could bore you with a bunch of tries I've done, using Widgets, trying to represent the JSON field through a form, through forms.ModelMultipleChoiceField (which by the way, seems to have been the closest thing to what I want, although still very far)... But I think that is kind of pointless, since nothing came close to what I wanted.

As usual, thank you for reading this huge email and thank you in advance. Any hint will be really appreciated, even just a you should take a look to "this"

Bor*_*jaX 6

所以......我很欣赏@alfonso.kim 的回答,但是为了“渲染”目的而创建一个全新的 Django 模型的想法对我来说听起来有点矫枉过正。请!不要误会我的意思:这可能是做这件事(我见过的方法推荐很多次)的“规范”的方式,也许是比什么更好的没有,但我想表现怎么解决我的问题,特别是:

我查看了 Django 的源代码,特别是如何ManyToMany在 Admin 中显示关系。如果您查看我上面的原始问题,我想弄清楚 Django在编辑一个产品时使用哪个类来显示类别(即“双列选择”,给它一个我非常喜欢的名称)。事实证明它是一个django.forms.models.ModelMultipleChoiceField“经验丰富”,带有FilteredSelectMultiple小部件的提示。

有了这些信息,我为我的班级创建了一个自定义管理表单Coupon,手动添加了我想要显示的字段:

class CouponAdminForm(forms.ModelForm):
    brands = forms.ModelMultipleChoiceField(
                            queryset=Brand.objects.all().order_by('name'),
                            required=False,
                            widget=FilteredSelectMultiple("Brands", is_stacked=False))
    categories = forms.ModelMultipleChoiceField(
                            queryset=Category.objects.all().order_by('name'),
                            required=False,
                            widget=FilteredSelectMultiple("Categories", is_stacked=False))
    products = forms.ModelMultipleChoiceField(
                            queryset=Product.objects.all().order_by('name'),
                            required=False,
                            widget=FilteredSelectMultiple("Products", is_stacked=False))

    def __init__(self, *args, **kwargs):
        # ... we'll get back to this __init__ in a second ... 

    class Meta:
        model = Coupon
        exclude = ('filter_by',)  # Exclude because we're gonna build this field manually
Run Code Online (Sandbox Code Playgroud)

然后告诉ModelAdmin我的优惠券班级使用该表格而不是默认表格:

class CouponsAdmin(admin.ModelAdmin):

    form = CouponAdminForm

# ... #
admin.site.register(Coupon, CouponsAdmin)
Run Code Online (Sandbox Code Playgroud)

这样做会在公式的根部显示三个表单的手动添加字段(brandcategoriesproducts)。换句话说:这在与我的模型中的其他字段相同的级别上产生了三个新字段。但是:它们并不是真正的“一流”字段,因为它们实际上是要确定我的模型(字段)中一个特定字段的内容,让我们记住,它是一个或多或少看起来像的字典:CouponCoupon.filter_by

filter_by = {
    "brands": [2, 3],
    "categories": [7]
}
Run Code Online (Sandbox Code Playgroud)

为了让使用 Admin 网页的人清楚这三个字段在 Coupon 模型中并不是“真正的”第一级字段,我决定将它们分组显示。

为此,我需要更改CouponsAdmin字段的布局。我不希望这个分组影响我Coupon模型的其他字段的显示方式,即使新字段后来添加到模型中,所以我让表单的所有其他字段保持不变(换句话说:只应用特殊/分组布局到brands,categoriesproducts表单中的字段)。令我惊讶的是,我无法在ModelForm课堂上做到这一点。我不得不去ModelAdmin(我真的不知道为什么......):

class CouponsAdmin(admin.ModelAdmin):
    def get_fieldsets(self, request, obj=None):
        fs = super(CouponsAdmin, self).get_fieldsets(request, obj)
        # fs now contains only [(None, {'fields': fields})] meaning, ungrouped fields
        filter_by_special_fields = (brands', 'categories', 'products')
        retval = [
            # Let every other field in the model at the root level
            (None, {'fields': [f for f in fs[0][1]['fields']
                               if f not in filter_by_special_fields]
                    }),
            # Now, let's create the "custom" grouping:
            ('Filter By', {
                'fields': ('brands', 'categories', 'products')
            })
        ]
        return retval

    form = CouponAdminForm
Run Code Online (Sandbox Code Playgroud)

关于这里的更多信息fieldsets

这就是诀窍:

管理页面中的过滤器

现在,当管理员用户Coupon通过此表单创建一个新表单时(换句话说:当用户单击页面上的“保存”按钮时),我将获得一个查询集,用于我在自定义表单中声明的​​额外字段(一个查询集用于brands,另一个用于categories和另一个用于products) 但我实际上需要将该信息转换为字典。我能够通过覆盖saveModel's Form的方法来实现这一点:

class CouponAdminForm(forms.ModelForm):
    brands = forms.ModelMultipleChoiceField(queryset=Brand.objects.all().order_by('name'),
                                            required=False,
                                            widget=FilteredSelectMultiple("Brands", is_stacked=False))
    categories = forms.ModelMultipleChoiceField(queryset=Category.objects.all().order_by('name'),
                                                required=False,
                                                widget=FilteredSelectMultiple("Categories", is_stacked=False))
    products = forms.ModelMultipleChoiceField(queryset=Product.objects.all().order_by('name'),
                                              required=False,
                                              widget=FilteredSelectMultiple("Products", is_stacked=False))

    def __init__(self, *args, **kwargs):
        # ... Yeah, yeah!! Not yet, not yet... 

    def save(self, commit=True):
        filter_by_qsets = {}
        for key in ['brands', 'categories', 'products']:
            val = self.cleaned_data.pop(key, None)  # The key is always gonna be in 'cleaned_data',
                                                    # even if as an empty query set, so providing a default is
                                                    # kind of... useless but meh... just in case
            if val:
                filter_by_qsets[key] = val  # This 'val' is still a queryset

        # Manually populate the coupon's instance filter_by dictionary here
        self.instance.filter_by = {key: list(val.values_list('id', flat=True).order_by('id'))
                                   for key, val in filter_by_qsets.items()}
        return super(CouponAdminForm, self).save(commit=commit)


    class Meta:
        model = Coupon
        exclude = ('filter_by',)
Run Code Online (Sandbox Code Playgroud)

filter_by"Save"上正确填充了优惠券的字典。

还剩下一些细节(使管理表单更加用户友好):在编辑现有 时 Coupon,我希望表单的brands,categoriesproducts字段预先填充filter_by优惠券字典中的值。

这就是修改Form__init__方法派上用场的地方(记住我们正在修改的实例可以在Form的属性中访问)self.instance

class CouponAdminForm(forms.ModelForm):
    brands = forms.ModelMultipleChoiceField(queryset=Brand.objects.all().order_by('name'),
                                            required=False,
                                            widget=FilteredSelectMultiple("Brands", is_stacked=False))
    categories = forms.ModelMultipleChoiceField(queryset=Category.objects.all().order_by('name'),
                                                required=False,
                                                widget=FilteredSelectMultiple("Categories", is_stacked=False))
    products = forms.ModelMultipleChoiceField(queryset=Product.objects.all().order_by('name'),
                                              required=False,
                                              widget=FilteredSelectMultiple("Products", is_stacked=False))

    def __init__(self, *args, **kwargs):
        # For some reason, using the `get_changeform_initial_data` method in the
        # CouponAdminForm(forms.ModelForm) didn't work, and we have to do it
        # like this instead? Maybe becase the fields `brands`, `categories`...
        # are not part of the Coupon model? Meh... whatever... It happened to me the
        # same it happened to this OP in stackoverflow: /sf/ask/1874985661/
        super(CouponAdminForm, self).__init__(*args, **kwargs)
        self.fields["brands"].initial = self.instance.filter_by.get('brands')
        self.fields["categories"].initial = self.instance.filter_by.get('categories')
        self.fields["products"].initial = self.instance.filter_by.get('products')

    def save(self, commit=True):
        filter_by_qsets = {}
        for key in ['brands', 'categories', 'products']:
        # ... explained above ...
Run Code Online (Sandbox Code Playgroud)

就是这样。

到目前为止(现在,2017 年 3 月 19 日)这似乎很好地满足了我的需要。

正如alfonso.kim在他的回答中指出的那样,除非我更改窗口的 Javascrip(或者我可能使用自定义模型?不知道:没有尝试过),否则我无法动态过滤不同的字段ChainedForeignKey我的意思是这种方法我无法过滤管理网页上的选择框,删除仅属于所选类别的产品,例如,我不能执行诸如“如果用户选择brand, 过滤器categoriesproducts因此它们只显示属于那个牌子”。发生这种情况是因为当用户选择一个品牌时,浏览器和服务器之间没有 XHR (Ajax) 请求。基本上:流程是您获取表格-->您填写表格-->您发布表单,当用户单击表单上的“事物”时,浏览器 <--> 服务器之间没有通信。如果用户在选择中选择“可口可乐” brands,那么products选择会被过滤,并plastic bags从可用产品中删除(例如),但是很好......这种方法“足够好”满足我的需求。

请注意:此答案中的代码可能包含一些多余的操作,或者本可以写得更好的内容,但到目前为止,它似乎工作正常(谁知道,也许我必须编辑我的答案一些几天后说“我完全错了!请不要这样做!”到目前为止似乎还可以)不用说:我欢迎任何人必须说的任何建议评论:-)

我希望这对未来的人有所帮助。