使用Django和MochiKit实现多级联动菜单

最近在python的邮件列表上看到有人问django如何实现多级联动菜单,我自己在做的一个项目也需要这个功能,但是找了半天也没有现成的解决方案,只好自己实现了一个。

由于我对JavaScript不是很熟,所以采用了现成的Ajax框架,粗略比较了一下,选择了比较 Pythonic 的 MochiKit。

Django没有绑定特定的Ajax框架有好有坏,好的方面,我们可以选择自己熟悉和喜欢的框架,坏的的方面,要和后台应用集成部分的工作就得全部自己来做了。

废话不多说了,直接贴代码和说下基本原理。由于大部分代码直接抽取自我现在在做的项目,所以可扩展性还没有到达很好的程度,但是如果理解的话还是可以很容易适应各种情况的需求。

所谓多级联动菜单,举个最常见的例子,就是大家在填很多资料的时候,会让你选择省,市,等资料。

如果只需要省一级的选择,django可以很好的处理这种情况,它会不遗余力的帮你把数据库中所有的省取出来,但是如果有二级市一级的选择,那么它显得有点自作多情了,还是照样全部取出来,恩,中国那么多市,先不管取出来要耗费多少计算资源,光是让用户去选就得看花眼了。而我们在别人的网站上填表单的时候常见的情况是选了省之后,它才会显示市列表,并且只显示该省的市。好了,来看看如何来实现这个功能吧。

假设有如下Model:

Python代码    收藏代码
  1. class Province(models.Model):  
  2.     name = models.CharField(max_length = 50)  
  3.       
  4.     def __unicode__(self):  
  5.         return self.name  
  6.       
  7. class City(models.Model):  
  8.     name = models.CharField(max_length = 50)  
  9.     province = models.ForeignKey(Province, related_name = "cities")  
  10.       
  11.     def __unicode__(self):  
  12.         return self.name  
  13.   
  14. class School(models.Model):  
  15.     name = models.CharField(max_length = 50)  
  16.     city = models.ForeignKey(City, related_name = "schools")  
  17.       
  18.     def __unicode__(self):  
  19.         return self.name  
  20.   
  21. class Profile(models.Model):  
  22.     user = models.OneToOneField(User, related_name = "profile")  
  23.     province = models.ForeignKey(Province, verbose_name = u'所在省', related_name = "profiles", null = True, blank = True)  
  24.     city = models.ForeignKey(City, verbose_name = u'所在城市', related_name = "profiles", null = True, blank = True)  
  25.     school = models.ForeignKey(School, verbose_name = u'所在学校', related_name = "profiles", null = True, blank = True)  
  26.       
  27.     def __unicode__(self):  
  28.         return self.user.username  

 

Profile Model 用来保存用户的资料。然后我们直接使用ModelForm从Profile Model创建一个Form吧,看django真是聪明,定义好了Model,其他很多事它都可以代劳。代码如下:

Python代码    收藏代码
  1. from django import forms  
  2. class ProfileForm(forms.ModelForm):  
  3.     class Meta:  
  4.         model = Profile  
  5.         exclude = ('user', )  

几行代码,就完成所有表单的HTML编写和后台数据验证工作, 注意我在上面生成的表单排除掉了user Field,我们可不想让用户来替别人乱填资料。

不是说有级联选择菜单吗?在哪里呢?别急,我们不需要动Form里的任何东西,这样,如果如果你的项目已经写了很多Form类,那样改起来就容易多了,因为只需要改别的地方。

接下来,先假设我们开始没预料到要使用多级联动菜单。那么你很可能有这样一个view来处理用户编辑自己资料的功能。

Python代码    收藏代码
  1. @login_required  
  2. def profile_edit(request):  
  3.     if request.method == 'POST':  
  4.         form = ProfileForm(request.POST, request.FILES, instance = request.profile)  
  5.         if form.is_valid():  
  6.             new_profile = form.save()  
  7.             request.user.message_set.create(message=u"你的资料已经成功修改。")  
  8.             return HttpResponseRedirect(reverse('paila_profile_edit', ))  
  9.     else:  
  10.         form =ProfileForm(instance = request.profile)  
  11.     return render_to_response('profile_edit.html',locals(), context_instance = RequestContext(request))  

 

上面的假设,我们已经通过django signal,在User Model创建的时候新实例的时候,自动创建了一个相应的profile,并且利用middleware 将相应的profile对象绑定到了request对象上。关于这些不明白的话,待会我会在下面列些参考资料。

恩,就这样,短的可怕,所有输入错误反馈,数据验证工作都已经做了。

看上去都不错,好了,直接在上面来加上多级联动菜单的功能吧。只需要在view的顶部加上几句代码,然后像是下面这样。

Python代码    收藏代码
  1. @login_required  
  2. def profile_edit(request):  
  3.     cascade_select_list = [('province''city', Province, City),('city''school', City, School), reverse('paila_profile_edit', )]  
  4.     if request.GET:  
  5.         return handle_cascade_select(request, ProfileForm, cascade_select_list)  
  6.     if request.method == 'POST':  
  7.         form = ProfileForm(request.POST, request.FILES, instance = request.profile)  
  8.         if form.is_valid():  
  9.             new_profile = form.save()  
  10.             request.user.message_set.create(message=u"你的资料已经成功修改。")  
  11.             return HttpResponseRedirect(reverse('paila_profile_edit', ))  
  12.     else:  
  13.         form =ProfileForm(instance = request.profile)  
  14.     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的代码:

Python代码    收藏代码
  1. from django import template  
  2. from django.utils.safestring import mark_safe  
  3. from django.conf import settings  
  4. register = template.Library()  
  5. @register.filter  
  6. def cascade_select(value):  
  7.     response_url = value.pop()  
  8.     mochikit_src = """ 
  9. <script src="%sMochiKit/MochiKit.js" type="text/javascript"></script> 
  10. """ % settings.MEDIA_URL  
  11.     script_output = u""" 
  12. <script type="text/javascript"> 
  13. function on_succeed_callback_%(event_element)s(res){ 
  14.     filter_element = MochiKit.DOM.getElement('id_%(filter_element)s'); 
  15.     filter_element.parentNode.innerHTML = res.responseText; 
  16.     if (select_changed_%(filter_element)s){ 
  17.         filter_element = MochiKit.DOM.getElement('id_%(filter_element)s'); 
  18.         MochiKit.Signal.connect(filter_element, "onchange", select_changed_%(filter_element)s); 
  19.     } 
  20. } 
  21.  
  22. function select_changed_%(event_element)s(eventObj){ 
  23.     target = eventObj.target(); 
  24.     d = MochiKit.Async.doSimpleXMLHttpRequest('%(response_url)s',{ '%(event_element)s': target.value } ); 
  25.     d.addCallback(on_succeed_callback_%(event_element)s); 
  26. } 
  27. event_element = MochiKit.DOM.getElement('id_%(event_element)s'); 
  28. MochiKit.Signal.connect(event_element, "onchange", select_changed_%(event_element)s); 
  29. </script> 
  30. """  
  31.     output = [mochikit_src]  
  32.     for event_element, filter_element, event_model, filter_model in value:  
  33.         output.append(script_output % {'event_element':event_element, 'filter_element':filter_element, 'response_url':response_url})  
  34.     return mark_safe(u'\n'.join(output))  
  35.   
  36. 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替换中间某级节点的话,那么他原本注册的事件监听就失效了,所以在上面的语句中有代码

Python代码    收藏代码
  1. if (select_changed_%(filter_element)s){  
  2.         filter_element = MochiKit.DOM.getElement('id_%(filter_element)s');  
  3.         MochiKit.Signal.connect(filter_element, "onchange", select_changed_%(filter_element)s);  
  4.     }  

来检测一下,过滤之后的某个下拉框是否也是要过滤其他下拉框的节点,从而再次注册监听事件。

 如何在模板中使用?,恩,大概像是下面这样:

Html代码    收藏代码
  1. {% block content %}  
  2. <h1>修改资料</h1>  
  3. {% if form.errors %}  
  4. <p class="errors">请修改下面的错误: {{ form.non_field_errors }}</p>  
  5. {% endif %}  
  6. <form method="post" action="" enctype="multipart/form-data">  
  7. <table>  
  8.     {{form}}  
  9. </table>  
  10. {%load trade_tags%}  
  11. {{cascade_select_list|cascade_select}}  
  12. <p class="submit"><input type="submit" value="修改"></p>  
  13. </form>  
  14. {% 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请求:

Python代码    收藏代码
  1. if request.GET:  
  2.         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的函数来完成。这是在相对常见的情况下可以采取的处理方法。

来看它的代码:

Python代码    收藏代码
  1. def handle_cascade_select(request, form_class, cascade_select_list):  
  2.     cascade_select_list.pop()  
  3.     form =form_class()  
  4.     for event_element, filter_element, event_model, filter_model in cascade_select_list:  
  5.         if event_element in request.GET:  
  6.             try:  
  7.                 event_element_object = get_object_or_404(event_model, id = int(request.GET[event_element]))  
  8.             except Exception:  
  9.                 form.fields[filter_element].queryset = filter_model.objects.none()  
  10.             else:  
  11.                 form.fields[filter_element].queryset = filter_model.objects.extra(where=['%s_id = %s' % (event_element, event_element_object.id)])  
  12.             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大概就是这样:

Python代码    收藏代码
  1. @login_required  
  2. def profile_edit(request):  
  3.     cascade_select_list = [('province''city', Province, City),('city''school', City, School), reverse('paila_profile_edit', )]  
  4.     if request.GET:  
  5.         return handle_cascade_select(request, ProfileForm, cascade_select_list)  
  6.     if request.method == 'POST':  
  7.         form = ProfileForm(request.POST, request.FILES, instance = request.profile)  
  8.         if form.is_valid():  
  9.             new_profile = form.save()  
  10.             request.user.message_set.create(message=u"你的资料已经成功修改。")  
  11.             return HttpResponseRedirect(reverse('paila_profile_edit', ))  
  12.     else:  
  13.         form =ProfileForm(instance = request.profile)  
  14.         if request.profile.province:  
  15.             form.fields['city'].queryset = request.profile.province.cities  
  16.         else:  
  17.             form.fields['city'].queryset = City.objects.none()  
  18.               
  19.         if request.profile.city:  
  20.             form.fields['school'].queryset = request.profile.city.schools  
  21.         else:  
  22.             form.fields['school'].queryset = School.objects.none()  
  23.     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/

你可能感兴趣的:(django)