最近在python的邮件列表上看到有人问django如何实现多级联动菜单,我自己在做的一个项目也需要这个功能,但是找了半天也没有现成的解决方案,只好自己实现了一个。
由于我对JavaScript不是很熟,所以采用了现成的Ajax框架,粗略比较了一下,选择了比较 Pythonic 的 MochiKit。
Django没有绑定特定的Ajax框架有好有坏,好的方面,我们可以选择自己熟悉和喜欢的框架,坏的的方面,要和后台应用集成部分的工作就得全部自己来做了。
废话不多说了,直接贴代码和说下基本原理。由于大部分代码直接抽取自我现在在做的项目,所以可扩展性还没有到达很好的程度,但是如果理解的话还是可以很容易适应各种情况的需求。
所谓多级联动菜单,举个最常见的例子,就是大家在填很多资料的时候,会让你选择省,市,等资料。
如果只需要省一级的选择,django可以很好的处理这种情况,它会不遗余力的帮你把数据库中所有的省取出来,但是如果有二级市一级的选择,那么它显得有点自作多情了,还是照样全部取出来,恩,中国那么多市,先不管取出来要耗费多少计算资源,光是让用户去选就得看花眼了。而我们在别人的网站上填表单的时候常见的情况是选了省之后,它才会显示市列表,并且只显示该省的市。好了,来看看如何来实现这个功能吧。
假设有如下Model:
class Province(models.Model): name = models.CharField(max_length = 50) def __unicode__(self): return self.name class City(models.Model): name = models.CharField(max_length = 50) province = models.ForeignKey(Province, related_name = "cities") def __unicode__(self): return self.name class School(models.Model): name = models.CharField(max_length = 50) city = models.ForeignKey(City, related_name = "schools") def __unicode__(self): return self.name class Profile(models.Model): user = models.OneToOneField(User, related_name = "profile") province = models.ForeignKey(Province, verbose_name = u'所在省', related_name = "profiles", null = True, blank = True) city = models.ForeignKey(City, verbose_name = u'所在城市', related_name = "profiles", null = True, blank = True) school = models.ForeignKey(School, verbose_name = u'所在学校', related_name = "profiles", null = True, blank = True) def __unicode__(self): return self.user.username
Profile Model 用来保存用户的资料。然后我们直接使用ModelForm从Profile Model创建一个Form吧,看django真是聪明,定义好了Model,其他很多事它都可以代劳。代码如下:
from django import forms class ProfileForm(forms.ModelForm): class Meta: model = Profile exclude = ('user', )
几行代码,就完成所有表单的HTML编写和后台数据验证工作, 注意我在上面生成的表单排除掉了user Field,我们可不想让用户来替别人乱填资料。
不是说有级联选择菜单吗?在哪里呢?别急,我们不需要动Form里的任何东西,这样,如果如果你的项目已经写了很多Form类,那样改起来就容易多了,因为只需要改别的地方。
接下来,先假设我们开始没预料到要使用多级联动菜单。那么你很可能有这样一个view来处理用户编辑自己资料的功能。
@login_required def profile_edit(request): if request.method == 'POST': form = ProfileForm(request.POST, request.FILES, instance = request.profile) if form.is_valid(): new_profile = form.save() request.user.message_set.create(message=u"你的资料已经成功修改。") return HttpResponseRedirect(reverse('paila_profile_edit', )) else: form =ProfileForm(instance = request.profile) return render_to_response('profile_edit.html',locals(), context_instance = RequestContext(request))
上面的假设,我们已经通过django signal,在User Model创建的时候新实例的时候,自动创建了一个相应的profile,并且利用middleware 将相应的profile对象绑定到了request对象上。关于这些不明白的话,待会我会在下面列些参考资料。
恩,就这样,短的可怕,所有输入错误反馈,数据验证工作都已经做了。
看上去都不错,好了,直接在上面来加上多级联动菜单的功能吧。只需要在view的顶部加上几句代码,然后像是下面这样。
@login_required def profile_edit(request): cascade_select_list = [('province', 'city', Province, City),('city', 'school', City, School), reverse('paila_profile_edit', )] if request.GET: return handle_cascade_select(request, ProfileForm, cascade_select_list) if request.method == 'POST': form = ProfileForm(request.POST, request.FILES, instance = request.profile) if form.is_valid(): new_profile = form.save() request.user.message_set.create(message=u"你的资料已经成功修改。") return HttpResponseRedirect(reverse('paila_profile_edit', )) else: form =ProfileForm(instance = request.profile) return render_to_response('profile_edit.html',locals(), context_instance = RequestContext(request))
是的,只加了3行代码,接下来说明一下顶部新添加加的代码的意思。
cascade_select_list 是一个关于表示级联菜单之间关系的一个数组。除了最后一个元素表示要将处理Ajax的请求发到哪个url外,所有前面的元素都是一个有另外四个元素组成的tuple。
在这个tuple中的四个元素分别表示:
1、要监听表单中onchange事件的下拉框的name
2、第一个参数对应的下拉框发生变化的时候,要刷新的另一个下拉框的name
3、第一个下拉框对应的Model
4、要刷新的下拉框对应的Model
cascade_select_list 最后一个参数是Ajax请求的url,通过named url 来反转,其实就是最后对应的view就是 profile_edit 。所有工作都在一个view做了,由于POST方法已经用来接收表单提交的处理,所以用GET方法来提交Ajax请求。到这里,我有必要先说明一下Ajax请求是如果发过来的,在HTML中到底多了JavaScript语句。
为了尽可能少的修改原有的Form类,但是js又必须知道要对哪个表单域进行事件监听,对哪个表单域进行过滤修改。前面我们看到,cascade_select_list 是关于这些信息很好的一个来源,而且事实上有这些信息就已经足够了。在这里,我试用了template filter 将 cascade_select_list 的数据直接进行分析,生成相应的js语句。下面是该filter的代码:
from django import template from django.utils.safestring import mark_safe from django.conf import settings register = template.Library() @register.filter def cascade_select(value): response_url = value.pop() mochikit_src = """ <script src="%sMochiKit/MochiKit.js" type="text/javascript"></script> """ % settings.MEDIA_URL script_output = u""" <script type="text/javascript"> function on_succeed_callback_%(event_element)s(res){ filter_element = MochiKit.DOM.getElement('id_%(filter_element)s'); filter_element.parentNode.innerHTML = res.responseText; if (select_changed_%(filter_element)s){ filter_element = MochiKit.DOM.getElement('id_%(filter_element)s'); MochiKit.Signal.connect(filter_element, "onchange", select_changed_%(filter_element)s); } } function select_changed_%(event_element)s(eventObj){ target = eventObj.target(); d = MochiKit.Async.doSimpleXMLHttpRequest('%(response_url)s',{ '%(event_element)s': target.value } ); d.addCallback(on_succeed_callback_%(event_element)s); } event_element = MochiKit.DOM.getElement('id_%(event_element)s'); MochiKit.Signal.connect(event_element, "onchange", select_changed_%(event_element)s); </script> """ output = [mochikit_src] for event_element, filter_element, event_model, filter_model in value: output.append(script_output % {'event_element':event_element, 'filter_element':filter_element, 'response_url':response_url}) return mark_safe(u'\n'.join(output)) cascade_select.is_safe = True
该filter从上到下各条语句的意思大概就是:
1、取出要将Ajax请求发到那个url的cascade_select_list 中的最后那个元素。
2、设置MochiKit本身的文件路径。
3、script_output是真正工作的脚本的一个模板,里面的有些字符串会被从cascade_select_list 取出来的数据替换,那样生成的js语句就可以在DOM结构中找到对应要监听事件和进行过滤的节点了。
4、从cascade_select_list 取出数据,对js模板进行替换,生成正式的js语句。
附加说明:
其实以上的js模板和语句,可以很容的被你自己喜欢的Ajax框架替换。所以我也不多解释js语句的意思了,只是,有一点需要注意的是,如果多级联动,而不是二级联动,那么就要到由于使用innerHTML替换中间某级节点的话,那么他原本注册的事件监听就失效了,所以在上面的语句中有代码
if (select_changed_%(filter_element)s){ filter_element = MochiKit.DOM.getElement('id_%(filter_element)s'); MochiKit.Signal.connect(filter_element, "onchange", select_changed_%(filter_element)s); }
来检测一下,过滤之后的某个下拉框是否也是要过滤其他下拉框的节点,从而再次注册监听事件。
如何在模板中使用?,恩,大概像是下面这样:
{% block content %} <h1>修改资料</h1> {% if form.errors %} <p class="errors">请修改下面的错误: {{ form.non_field_errors }}</p> {% endif %} <form method="post" action="" enctype="multipart/form-data"> <table> {{form}} </table> {%load trade_tags%} {{cascade_select_list|cascade_select}} <p class="submit"><input type="submit" value="修改"></p> </form> {% endblock %}
与原来相比只修改,只修改了一个地方,在form下面加载进新定义的 cascade_select filter的所在的Module,对 cascade_select_list 使用该filter,就可以生成需要的js语句了。
最终以上生成的js语句会在change事件发生的时候对指定的url发起GET请求,将该表单域的name和value作为参数传递给服务器。比如province改变的话会产出类似下面url进行请求:
/accounts/profile/edit/?province=1
好了,既然Ajax请求进来了,那么再回来说说view函数该如何处理。
在view函数的开头新加的语句里只有两句用来应付新增加AJax请求:
if request.GET: return handle_cascade_select(request, ProfileForm, cascade_select_list)
这里if request.GET:是用来判断是否有GET参数传进来,即结尾的?province=1这样的查询字符串,由于整个表单本身的数据是通过POST提交的,所以光这个就可以区分开这个view要处理的3种情况:
1、直接打开url要进行profile修改时,即既没有GET也没有POST参数。
2、Ajax请求,只有GET参数
3、表单提交请求,只有POST参数
当然这里的判断是简单了点,如果你的实际情况复杂还是很容易修改的的。
好了,如果是一个Ajax请求的话,所有工作就交给一个叫做handle_cascade_select的函数来完成。这是在相对常见的情况下可以采取的处理方法。
来看它的代码:
def handle_cascade_select(request, form_class, cascade_select_list): cascade_select_list.pop() form =form_class() for event_element, filter_element, event_model, filter_model in cascade_select_list: if event_element in request.GET: try: event_element_object = get_object_or_404(event_model, id = int(request.GET[event_element])) except Exception: form.fields[filter_element].queryset = filter_model.objects.none() else: form.fields[filter_element].queryset = filter_model.objects.extra(where=['%s_id = %s' % (event_element, event_element_object.id)]) return HttpResponse(str(form[filter_element]))
它接受一个request对象,一个表单类,在这里我们要传的是ProfileForm,以及用来表示级联关系的cascade_select_list。
简单来说他就是再次实例化整个ProfileForm,然后根据request.GET中的参数名和参数值,即类似 ?province=1 这样的名值对。遍历 cascade_select_list 中是否有相应的需要处理的表单域name。由于在cascade_select_list 还设置了相应的事件和要过滤的Model类,那么如果cascade_select_list 中有相应的需要处理的表单域name就可以进行以下简单的处理(在这里以?province=1为例):
1、找到 Province id为 1 的数据库记录,如果找不到或产生其他任何异常,那么进行 2,否则为 3
2、将ProfileForm中相应的要过滤的那个表单域在这里是city的queryset设置为空queryset。
3、如果查到了Province id为 1的数据库记录,那么就查到City 模型province_id为刚刚找到的province的id。从而找出该省所有的城市。并且将该结果作为要过滤的那个表单域的queryset。
可以看到这里处理第三步的情况的是要符合很多条件的:
ProfileForm中表单域的name刚好和Model中对应的字段名字一致。
要过滤的Model在数据库表中对应的要查询的外键名刚好是 'name'_id的形式(当然这是的django帮你生成SQL时默认情况)。
所以,如果是更复杂的过滤条件还是请自己写点代码来处理吧。
现在要过滤的那个表单域的queryset已经被修改了,我们只需要该表单域,而不是整个表单,所以只取出该表单域,然后作为HttpResponse对象返回给浏览器。浏览器就可以收到只包含修改后queryset对应的选项的一个下拉框了。
浏览器直接将改反馈结果作为innerHTML替换原来的那个表单域即可。由于替换前后两个表单域都是通过ProfileForm来生成的,所以替换的结果,显而易见除了下拉选项不同,其他完全相同。
至此,一个多级联动菜单就完成了。其实上面还没处理一个情况就是用户第一次打开页面的时候,即:
1、直接打开url要进行profile修改时,即既没有GET也没有POST参数。
各级下拉框依旧包含所有选项,所以这种情况下最好还是对ProfileForm中那些一级以下的表单域的queryset进行一下修改。
最后修改的view大概就是这样:
@login_required def profile_edit(request): cascade_select_list = [('province', 'city', Province, City),('city', 'school', City, School), reverse('paila_profile_edit', )] if request.GET: return handle_cascade_select(request, ProfileForm, cascade_select_list) if request.method == 'POST': form = ProfileForm(request.POST, request.FILES, instance = request.profile) if form.is_valid(): new_profile = form.save() request.user.message_set.create(message=u"你的资料已经成功修改。") return HttpResponseRedirect(reverse('paila_profile_edit', )) else: form =ProfileForm(instance = request.profile) if request.profile.province: form.fields['city'].queryset = request.profile.province.cities else: form.fields['city'].queryset = City.objects.none() if request.profile.city: form.fields['school'].queryset = request.profile.city.schools else: form.fields['school'].queryset = School.objects.none() return render_to_response('profile_edit.html',locals(), context_instance = RequestContext(request))
可以看到,Django虽然没有绑定任何Ajax框架,但是借助已有的Ajax框架要实现动态的功能还是很简单的。尤其是借助自定义template filter 和tag 技术,完全可以将现有的Ajax框架封装起来,形成像ROR有的那样一个比较好用的Ajax库。
参考资料:
1、Signal:我写的另外一篇文章 使用Django的 signals 和 contenttypes 实现新鲜事功能,虽然有点旧了,还是需要看一下新的django文档的。
2、Middleware: http://docs.djangoproject.com/en/dev/topics/http/middleware/
3、template: http://docs.djangoproject.com/en/dev/topics/templates/
4、QuerySet API 的 extra方法: http://docs.djangoproject.com/en/dev/ref/models/querysets/#extra-select-none-where-none-params-none-tables-none-order-by-none-select-params-none
5、MochiKit: http://mochikit.com/