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

Django admin page: Customize dictionary (JSONField) of IDs through multiple Models selects instead of raw text

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

要存储的 Json 有一个可变的 ID 字典引用数据库中的其他项目(可能 relations/attributes)。

请允许我说得更具体一点:

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

例如:

这个想法似乎足够灵活,可以满足我的需要,我(到目前为止)对此很满意。


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

正如预期的那样,filter_by 字典呈现为最初看起来像这样的文本字段:

如果我想向它添加字段,我需要写下我想要的确切 JSON...这意味着如果我想对 "Beverages"类别,我需要去弄清楚该类别在数据库中的ID,然后手动输入{"category": [5]},同时在输入'时要格外小心, :,确保我不会错过 ][...

Thaaaat...好吧,这不是很有帮助...

因为我只会按几个字段(categorymanufacturerproduct...)进行过滤,这些字段实际上是数据库,我想为我可以过滤的每个 thingy 显示一个大的 MultiSelect 框,这样我就可以看到一个用户友好的列表,其中包含我可以过滤的所有元素,select一些,然后,当我点击 "Create discount" 时,我会得到 filter_by 字典(我还远没有担心如何生成字典,因为我什至不知道如何正确呈现管理表单)。

类似于 Django Admin 自动为我的产品类别所做的事情:

这真是太好了:一个产品可以属于多个类别。为此,Django 并排呈现两个 <select multiple 框,其中包含可用类别以及产品已经属于的类别...我可以通过鼠标划动 add/remove 个类别。 .. 真的,真的很好。但是 Django 可以做到这一点,因为它知道 categoriesProduct 模型中的 ManyToMany 关系。

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)

Discount 模型的问题是 categorymanufacturerproduct 没有 ManyToMany 字段。可怜的 Django 不知道 Discount 与所有这些东西相关:它只知道有一个 Json 字段。

我真的很想能够在 Django 区域中展示一堆 <select> 列出所有可能的过滤器(CategoryManufacturerID ...) 可以存储在 filter_by 字典中(一个带有双 <select> 的条目表示 Category 显示数据库中所有可用的类别,一个条目表示 Manufacturer ,显示所有可用的制造商......等等)。但是我真的,​​真的不知道该怎么做。

我可能会用我做过的一堆尝试让你厌烦,使用 Widgets,试图通过 form,通过 forms.ModelMultipleChoiceField 来表示 JSON 字段(顺便说一下,这似乎是最接近我想要的东西,虽然仍然很远)......但我认为这有点毫无意义,因为没有什么能接近我想要的。

像往常一样,感谢您阅读这封巨大的电子邮件,并提前致谢。任何提示都将不胜感激,即使只是 你也应该看看 "this"

您将需要一些 javascript 来将 json 字典放入一个漂亮的 HTML 小部件中,然后在 Django 处理程序中对其进行处理。

如果你想使用 Django admin 的 "magic",你必须给它提供它需要的输入以呈现漂亮的 UI 并为你的折扣系统创建模型:

class Discount(models.Model):
  discount_type = models.TextField()
  discount_percentage = models.FloatField()

class DiscountElement(models.Model):
  discount = models.ForeignKey(Discount)
  manufacturer = models.ForeignKey(Manufacturer, null=True)
  category = models.ForeignKey(Category, null=True)

所以...我很欣赏@alfonso.kim的,但是为"rendering"[=116创建一个全新的Django模型的想法=] 目的对我来说听起来有点矫枉过正。请!不要误会我的意思:这可能是 "canonical" 的做法(我已经多次看到这种方法被推荐)并且可能比 I 做的更好,但我想展示 I 如何解决我的特定问题:

我查看了 Django 的源代码,特别是 ManyToMany 关系在 Admin 中的显示方式。如果你看看我上面的原始问题,我想弄清楚 Django 在编辑一个 product[=116= 时使用哪个 class 来显示 categories ](那个"double column select",给它起一个我非常喜欢的名字)。原来它是一个 django.forms.models.ModelMultipleChoiceField, "seasoned" with a hint of a FilteredSelectMultiple 小部件。

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

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

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

class CouponsAdmin(admin.ModelAdmin):

    form = CouponAdminForm

# ... #
admin.site.register(Coupon, CouponsAdmin)

这样做会在处方集的 root 处显示三个表单的手动添加字段(brandcategoriesproducts)。换句话说:这产生了与我的 Coupon 模型中其余字段处于同一级别的三个新字段。但是:它们并不是真正的 "first class" 字段,因为它们实际上是要确定我的模型(Coupon.filter_by 字段)中一个特定字段的内容,让我们记住,它看起来或多或少是一本字典喜欢:

filter_by = {
    "brands": [2, 3],
    "categories": [7]
}

为了向使用管理网页的人表明这三个字段不是优惠券模型中的 "really" 第一级字段,我决定将它们分组显示。

为此,我需要更改字段的 CouponsAdmin 布局。我不希望此分组影响我的 Coupon 模型的其他字段的显示方式,即使后来将新字段添加到模型中也是如此,所以我让表单的所有其他字段保持不变(换句话说:仅将 special/grouped 布局应用于表单中的 brandscategoriesproducts 字段)。令我惊讶的是,我无法在 ModelForm class 中执行此操作。我不得不去 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

更多信息 fieldsets here

成功了:

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

save 方法来实现这一点
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',)

"Save".

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

还有一些细节(使管理表单对用户更友好):在编辑 现有 Coupon 时,我想要 [=表格的 26=]、categoriesproducts 字段将预填充优惠券 filter_by 字典中的值。

这里是修改 Form__init__ 方法的地方(请记住,我们正在修改的实例可以在 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 Whosebug: 
        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 ...

就是这样。

截至目前(右现在,2017 年 3 月 19 日)这似乎可以很好地满足我的需要。

作为 alfonso.kim points out in his answer, I can not dynamically filter the different fields unless I change the window's Javascrip (or maybe I use the ChainedForeignKey 自定义模型?不知道:没试过)我的意思是,通过这种方法,我无法过滤管理网页上的 select 框,删除仅属于 selected 类别的产品,例如,我不能做 "if a user selects a brand, filter categories and products so they only show elements that belong to that brand" 这样的事情。发生这种情况是因为当用户 select 是品牌时,浏览器和服务器之间没有 XHR (Ajax) 请求。基本上:流程是你获取表格 --> 你填写表格 --> 你POST 表单,当用户在表单上单击 "things" 时,浏览器 <--> 服务器之间没有通信。如果 brands select 中的用户 selects "Coca cola",products select 被过滤,并删除 plastic bags 来自可用的产品(例如)但是好吧......这种方法 "good enough" 满足我的需要。

请注意:此答案中的代码可能包含一些多余的操作,或者可以写得更好的东西,但到目前为止,它似乎工作正常(谁知道,也许我必须几天后编辑我的答案说 "I was completely wrong!! Please don't do this!" 到目前为止 似乎没问题)不用说:我欢迎任何评论建议任何人都必须说 :-)

我希望这对以后的人有所帮助。