您的位置:首页 > 编程语言 > Go语言

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

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

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

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

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

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

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

假设有如下Model:

Python代码



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,其他很多事它都可以代劳。代码如下:

Python代码



from django import forms

class ProfileForm(forms.ModelForm):

class Meta:

model = Profile

exclude = ('user', )

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

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

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

Python代码



@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的顶部加上几句代码,然后像是下面这样。

Python代码



@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的代码:

Python代码



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替换中间某级节点的话,那么他原本注册的事件监听就失效了,所以在上面的语句中有代码

Python代码



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);

}

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

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

Html代码



{% 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请求:

Python代码



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的函数来完成。这是在相对常见的情况下可以采取的处理方法。

来看它的代码:

Python代码



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大概就是这样:

Python代码



@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/
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: