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
为该类所在的app
和model
名称组成的元组。定义函数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的实现方法基本类似,只是在判断是否亮起修改、删除按钮的逻辑上有些许改变,不再赘述。