• 使用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/

  • 相关阅读:
    Linux之wget命令
    Markdown语法
    Windows实时预览markdown
    Python基础教程,Python入门教程(非常详细)
    【转载】UNICODE与ASCII的区别
    Python之虚拟环境
    Linux文件系统管理
    Linux权限管理
    linux用户和用户组管理
    linux 软件安装
  • 原文地址:https://www.cnblogs.com/lddhbu/p/2592126.html
Copyright © 2020-2023  润新知