跳转至

Django弹窗新增、修改、删除ForeignKey、ManyToMany

Update 2018/05/15

已封装为轮子,需要使用的小伙伴请直接访问 Github 或者 示例网站

需要了解工作原理的请继续阅读。

原因

Django自带的admin中可以使用PopupWindow来新增和修改ForeignKey(简称FK)、新增ManyToMany(简称M2M)字段,可以在新增或者修改某个对象时快速的修改与其相关的其他实例,使用起来方便快捷。在实例项目开发过程中,遇到了相关的类似需求,在此记录下相关的实现方法。

可行的实现方案

Ajax

最开始考虑使用Ajax以及DjangoRestFramework来实现这项功能,但是缺点十分明显,对于纯Web的项目来说需要引入第三方库,新增、修改、删除FK\M2M时,其中的逻辑错误均需要手动去处理,比较繁琐并且不利于移植复用。

直接调用 RelatedFieldWidgetWrapper

在StackOverflow上搜索得到的答案均为使用RelatedFieldWidgetWrapper调用原生admin的PopupWindow,缺点比较明显,对于有一套自己的UI风格的Web系统来说,界面间的风格差异是不可容忍的。

JS

参考了Django 1.11.6 admin中的实现代码,理出其实现的主要思路如下:

  • RelatedFieldWidgetWrapper是个继承于forms.Widget的类,源码(/django/contrib/admin/widgets.py)如下(未显示部分无关代码):

class RelatedFieldWidgetWrapper(forms.Widget): """ This class is a wrapper to a given widget to add the add icon for the admin interface. """ templatename = 'admin/widgets/relatedwidget_wrapper.html'

def __init__(self, widget, rel, admin_site, can_add_related=None,
             can_change_related=False, can_delete_related=False):
    self.needs_multipart_form = widget.needs_multipart_form
    self.attrs = widget.attrs
    self.choices = widget.choices
    self.widget = widget
    self.rel = rel
    # Backwards compatible check for whether a user can add related
    # objects.
    if can_add_related is None:
        can_add_related = rel.model in admin_site._registry
    self.can_add_related = can_add_related
    # XXX: The UX does not support multiple selected values.
    multiple = getattr(widget, 'allow_multiple_selected', False)
    self.can_change_related = not multiple and can_change_related
    # XXX: The deletion UX can be confusing when dealing with cascading deletion.
    cascade = getattr(rel, 'on_delete', None) is CASCADE
    self.can_delete_related = not multiple and not cascade and can_delete_related
    # so we can check if the related object is registered with this AdminSite
    self.admin_site = admin_site

def get_related_url(self, info, action, *args):
    return reverse("admin:%s_%s_%s" % (info + (action,)),
                   current_app=self.admin_site.name, args=args)

def get_context(self, name, value, attrs):
    from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR
    rel_opts = self.rel.model._meta
    info = (rel_opts.app_label, rel_opts.model_name)
    self.widget.choices = self.choices
    url_params = '&'.join("%s=%s" % param for param in [
        (TO_FIELD_VAR, self.rel.get_related_field().name),
        (IS_POPUP_VAR, 1),
    ])
    context = {
        'rendered_widget': self.widget.render(name, value, attrs),
        'name': name,
        'url_params': url_params,
        'model': rel_opts.verbose_name,
    }
    if self.can_change_related:
        change_related_template_url = self.get_related_url(info, 'change', '__fk__')
        context.update(
            can_change_related=True,
            change_related_template_url=change_related_template_url,
        )
    if self.can_add_related:
        add_related_url = self.get_related_url(info, 'add')
        context.update(
            can_add_related=True,
            add_related_url=add_related_url,
        )
    if self.can_delete_related:
        delete_related_template_url = self.get_related_url(info, 'delete', '__fk__')
        context.update(
            can_delete_related=True,
            delete_related_template_url=delete_related_template_url,
        )
    return context

接收rel参数,其为一个FK类,在get_context函数中,rel_opts为该类的Meta属性,info为该类所在的appmodel名称组成的元组。定义函数get_related_url用于生成相关的新增、修改、删除url,url_params默认有TO_FIELD_VAR用于标记修改的field,IS_POPUP_VAR用于标记是以PopupWindow的方式打开。

  • 处理前面生成的url的视图代码位于/django/contrib/options.py/ModelAdmin/。处理新增为函数response_add,截取处理其类型为popup的部分代码如下:
if IS_POPUP_VAR in request.POST:
    to_field = request.POST.get(TO_FIELD_VAR)
    if to_field:
        attr = str(to_field)
    else:
        attr = obj._meta.pk.attname
    value = obj.serializable_value(attr)
    popup_response_data = json.dumps({
        'value': six.text_type(value),
        'obj': six.text_type(obj),
    })
    return TemplateResponse(request, self.popup_response_template or [
        'admin/%s/%s/popup_response.html' % (opts.app_label, opts.model_name),
        'admin/%s/popup_response.html' % opts.app_label,
        'admin/popup_response.html',
    ], {
        'popup_response_data': popup_response_data,
    })

popup_response_data 是一个包含了新增的FK\M2M的value(显示的值)、obj(新增的对象实例ID)json,函数返回的为TemplateResponse,生效部分为'admin/popup_response.html',其源代码如下:

{% load i18n static %}<!DOCTYPE html>
<html>
  <head><title>{% trans 'Popup closing...' %}</title></head>
  <body>
    <script type="text/javascript"
            id="django-admin-popup-response-constants"
            src="{% static "admin/js/popup_response.js" %}"
            data-popup-response="{{ popup_response_data }}">
    </script>
  </body>
</html>

只包含了一个admin/js/popup_response.js文件,其源码如下:

/*global opener */
(function() {
    'use strict';
    var initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse);
    switch(initData.action) {
    case 'change':
        opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value);
        break;
    case 'delete':
        opener.dismissDeleteRelatedObjectPopup(window, initData.value);
        break;
    default:
        opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj);
        break;
    }
})();

这段js将关闭PopupWindow并将得到的popup_response_data插入或者修改至对应的field,值得注意的是,admin在寻找插入的field_id是根据打开的PopupWindow的url,使用位于django/contrib/admin/static/js/admin/RelatedObjectLookups.js中的windowname_to_id函数从当前窗口的url截取并生成其在父页面待操作的field_id

这样的实现方式的优点是便于使用modelform以及相关逻辑,让其自动生成相关新增、修改、删除界面并处理相关错误,便于复用移至,但是需要对样式进行修改。

FK实现方法

  • 定义一个Widget:

from django.forms.widgets import Select

class ForeignKeyWidget(Select):
    template_name = 'widgets/foreign_key_select.html'

    def __init__(self, url_template, *args, **kw):
        super(ForeignKeyWidget, self).__init__(*args, **kw)
        self.url_template = url_template

    def get_context(self, name, value, attrs):
        context = super(ForeignKeyWidget, self).get_context(name, value, attrs)
        context['add_url'] = self.url_template
        context['update_url'] = self.url_template
        context['delete_url'] = self.url_template + 'delete/'
        return context

其接受一个url_template参数,如url_template/post/,则默认的url为:

新增: /post/; 修改: /post/id/; 删除: /post/delete/id/

  • 写该Widget对应的template,示例中使用layui2.0作为popup的打开方式,可以根据需要自行选择。

<style>
    #{{ widget.attrs.id }}_add, #{{ widget.attrs.id }}_change, #{{ widget.attrs.id }}_delete {
        margin-top: 10px;
        padding: 0 10px;
        height: 25px;
        line-height: 25px;
    }
</style>

<div class="row">
    <div class="col-sm-6">
        {% include "django/forms/widgets/select.html" %}
    </div>

    <div class="col-sm-6 layui-btn-group">
        <a class="layui-btn layui-btn-mini" id="{{ widget.attrs.id }}_add">新增</a>
        <a class="layui-btn layui-btn-mini layui-btn-disabled layui-btn-normal" id="{{ widget.attrs.id }}_change">修改</a>
        <a class="layui-btn layui-btn-mini layui-btn-disabled layui-btn-danger" id="{{ widget.attrs.id }}_delete">删除</a>
    </div>
</div>

<script>
    $('#{{ widget.attrs.id }}_add').click(function () {
        var index = layui.layer.open({
            title: "添加分类",
            type: 2,
            area: ['700px', '500px'],
            content: "{{ add_url }}" + '?popup=1&to_field={{ widget.attrs.id }}',
            success: function (layer, index) {

            }
        });
    });

    $("#{{ widget.attrs.id }}_change").click(function () {
        var id = $('#{{ widget.attrs.id }}').val();
        if (id) {
            var index = layui.layer.open({
                title: "修改分类",
                type: 2,
                area: ['700px', '500px'],
                content: '{{ update_url }}' + id + '?popup=1&to_field={{ widget.attrs.id }}',
                success: function (layer, index) {

                }
            });
        }
    });

    $("#{{ widget.attrs.id }}_delete").click(function () {
        var id = $('#{{ widget.attrs.id }}').val();
        var value = $('#{{ widget.attrs.id }} option[value=' + id + ']').text();
        var indexGood = value.lastIndexOf('>');
        var valueN = indexGood > 0 ? value.substring(indexGood + 1, value.length) : value;
        if (id) {
            layer.confirm('确认删除 ' + valueN + ' 吗?', {icon: 3, title: '删除'}, function (index) {
                $.ajax({
                    type: "POST",
                    data: {},
                    url: '{{ delete_url }}' + id + '/',
                    beforeSend: function (xhr) {
                        xhr.setRequestHeader("X-CSRFToken", $.cookie('csrftoken'));
                    },
                    success: function (data, textStatus) {
                        <!--关闭弹窗 返回列表 -->
                        layer.close(index);
                        $('#{{ widget.attrs.id }} option[value=' + data.id + ']').remove();
                        $("#{{ widget.attrs.id }}_change,#{{ widget.attrs.id }}_delete").addClass('layui-btn-disabled');

                        return false;
                    },
                    error: function (XMLHttpRequest, textStatus, errorThrown) {
                        layer.alert('删除失败 ' + XMLHttpRequest.responseText)
                    }
                });
            });
        }
    });

    /********select绑定change事件,如果value有值,就可修改及删除  页面加载完成之后做相同判断**********/
    function {{ widget.attrs.id }}_isDisabled() {
        if ($('#{{ widget.attrs.id }}').val()) {
            $("#{{ widget.attrs.id }}_change,#{{ widget.attrs.id }}_delete").removeClass('layui-btn-disabled');
        } else {
            $("#{{ widget.attrs.id }}_change,#{{ widget.attrs.id }}_delete").addClass('layui-btn-disabled');
        }
    }

    $('#{{ widget.attrs.id }}').change(function () {
        {{ widget.attrs.id }}_isDisabled();
    });

    {{ widget.attrs.id }}_isDisabled();
</script>

以修改分类为例,其popup打开的url为'{{ update_url }}' + id + '?popup=1&to_field={{ widget.attrs.id }}',url中的参数包含了popup=1用于标记以popup方式打开,to_field直接取了该控件的id而没有想admin中那样直接去url中截取,主要是因为一般项目的url模式没有admin中那样规整。

  • forms.py声明控件

from django import forms
from common.fields import ForeignKeyWidget
from django.core.urlresolvers import reverse_lazy

from .models import *


class ArticleForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super(ArticleForm, self).__init__(*args, **kwargs)

        self.fields['title'].widget.attrs.update({'class': 'form-control'})
        self.fields['category'].widget.attrs.update({'class': 'form-control'})

    class Meta:
        model = Article
        fields = ['title', 'category']
        widgets = {
            'category': ForeignKeyWidget(url_template=reverse_lazy('category_popup_create')),
        }


class CategoryForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super(CategoryForm, self).__init__(*args, **kwargs)
        self.fields['name'].widget.attrs.update({'class': 'form-control'})
        self.fields['parent'].widget.attrs.update({'class': 'form-control'})

    class Meta:
        model = Category
        fields = ['name', 'parent']
  • views.py

# ------------------------------ 博客类型Popup 开始 ------------------------------
class CategoryPopupCreateView(PermissionRequiredMixin, CreateView):
    form_class = CategoryForm
    template_name = 'category_popup/create.html'
    permission_required = 'article.add_category'

    def get_context_data(self, **kwargs):
        if 'to_field' in self.request.GET:
            kwargs['to_field'] = self.request.GET['to_field']
        return super(CategoryPopupCreateView, self).get_context_data(**kwargs)

    def form_valid(self, form):
        self.object = form.save()
        context = {'op': 'create', 'id': self.object.id, 'value': self.object.__str__()}
        if 'to_field' in self.request.GET:
            context['to_field'] = self.request.GET['to_field']
        return TemplateResponse(self.request, 'category_popup/success.html', context=context)


class CategoryPopupUpdateView(PermissionRequiredMixin, UpdateView):
    model = Category
    form_class = CategoryForm
    slug_field = 'id'
    context_object_name = 'category'
    template_name = 'category_popup/update.html'
    permission_required = 'article.change_category'

    def get_context_data(self, **kwargs):
        if 'to_field' in self.request.GET:
            kwargs['to_field'] = self.request.GET['to_field']
        return super(CategoryPopupUpdateView, self).get_context_data(**kwargs)

    def form_valid(self, form):
        self.object = form.save()
        context = {'op': 'update', 'id': self.object.id, 'value': self.object.__str__()}
        if 'to_field' in self.request.GET:
            context['to_field'] = self.request.GET['to_field']
        return TemplateResponse(self.request, 'category_popup/success.html', context=context)


class CategoryPopupDeleteView(PermissionRequiredMixin, DeleteView):
    model = Category
    slug_field = 'id'
    permission_required = 'article.delete_category'

    def delete(self, request, *args, **kwargs):
        self.object = self.get_object()
        data = {'op': 'delete', 'id': self.object.id, 'value': self.object.__str__()}
        self.object.delete()
        return JsonResponse(data=data)

# ------------------------------ 博客类型Popup 结束 ------------------------------

需要注意的是,url参数中的tofield参数在get->post的过程中需要一直被记录下来,则在getcontext_data中获取该参数,传递至category_popup/update.html,其源码如下:

{% extends "category_popup/base.html" %}

{% block main %}
    <div style="margin: 4px">
        <form class="form-horizontal" enctype="multipart/form-data"
              action="{% url 'category_popup_update' category.id %}{% if to_field %}?to_field={{ to_field }}{% endif %}"
              method="post">
            {% include 'widgets/form.html' %}
            <div class="form-group">
                <div class="col-sm-10">
                    <input class="btn btn-raised btn-info" type="submit" value="修改分类"/>
                </div>
            </div>
        </form>
    </div>
{% endblock %}

success.html代码:

{% extends "category_popup/base.html" %}

{% block main %}
    <script>
        var to_field = '#{{ to_field }}', op = '{{ op }}', id = '{{ id }}', value = '{{ value }}';
        if (to_field) {
            switch (op) {
                case 'create':
                    if (id) {
                        var index = parent.layer.getFrameIndex(window.name); //先得到当前iframe层的索引
                        parent.layer.close(index); //再执行关闭
                        $option = '<option value=' + id + ' selected>' + value + '</option>';
                        $(to_field, window.parent.document).append($option);
                        $(to_field + '_change,' + to_field + '_delete', window.parent.document).removeClass('layui-btn-disabled');
                    }
                    break;
                case 'update':
                    if (id) {
                        var index = parent.layer.getFrameIndex(window.name); //先得到当前iframe层的索引
                        parent.layer.close(index); //再执行关闭
                        $(to_field + ' option[value=' + id + ']', window.parent.document).html(value);
                    }
                    break;
            }
        }
    </script>
{% endblock %}
  • urls.py

category_urlpatterns = [
    url(r'^popup/$', CategoryPopupCreateView.as_view(), name='category_popup_create'),
    url(r'^popup/(?P<pk>\d+)/$', CategoryPopupUpdateView.as_view(), name='category_popup_update'),
    url(r'^popup/delete/(?P<pk>\d+)/$', CategoryPopupDeleteView.as_view(), name='category_popup_delete'),
]

M2M实现

M2M实现与FK的实现方法基本类似,只是在判断是否亮起修改、删除按钮的逻辑上有些许改变,不再赘述。

最终的效果

img