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

最近在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 = """

""" % settings.MEDIA_URL
    script_output = u"""

"""
    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 %}

修改资料

{% if form.errors %}

请修改下面的错误: {{ form.non_field_errors }}

{% endif %}
{{form}}
{%load trade_tags%} {{cascade_select_list|cascade_select}}

{% 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/

你可能感兴趣的:(Django)