SITE OBSOLETE. GO AWAY.

Visit uranusjr.com for updates.

動態欄位的 Reusable Search Mixin View

Python, django, 2sod, taipei.py,

本周的 Django Workshop(2013-12-24) 講到了 Two Scoops of Django 第九章: Common Patterns of Forms。Alfred 在最後提到書上有一個 Reusable Search Mixin View,但是說以他自己的狀況而言,因為跨 model 的 field names 通常不一樣,所以這個 pattern 比較派不上用場。

當時我補了一句「其實這個還是有解」,本來打算稍後討論的時候提出來,不過後來被 Andy 拱上去講 naming convention 就完全忘了這件事,直到現在。 orz

所以我現在要來彌補這個錯誤。

為了那些沒有看過 Two Scoops of Django 的人(如果你覺得自己算是個 Django programmer,我強烈建議你馬上買來看),Reusable Search Mixin View 是這樣的概念:我們通常會想在網站上做某個 model 的搜尋結果頁面,而且可能會有不止一個 model 需要能搜尋。與其為每個 model 建立一個 view class 來顯示,不如寫一個這樣的 mixin:

# core/views.py 

class TitleSearchMixin(object): 
 
    def get_queryset(self): 
        # Fetch the queryset from the parent’s get_queryset 

        queryset = super(TitleSearchMixin, self).get_queryset() 
 
        # Get the q GET parameter

        q = self.request.GET.get("q") 
        if q: 
            # return a filtered queryset 

            return queryset.filter(title__icontains=q) 
        # No q is specified so we return queryset 

        return queryset

然後你就可以這樣建立搜尋結果頁:

# add to stores/views.py 

from django.views.generic import ListView 
 
from core.views import TitleSearchMixin 
from .models import Store 
 
class IceCreamStoreListView(TitleSearchMixin, ListView): 
    model = Store
# add to flavors/views.py 

from django.views.generic import ListView 
 
from core.views import TitleSearchMixin 
from .models import Flavor 
 
class IceCreamFlavorListView(TitleSearchMixin, ListView): 
    model = Flavor

這樣做的好處是,你根本不用寫 form!只要做兩個 templates:

{# form to go into stores/store_list.html template #} 
<form action="" method="GET"> 
    <input type="text" name="q" /> 
    <button type="submit">search</button> 
</form>
{# form to go into flavors/flavor_list.html template #} 
<form action="" method="GET"> 
    <input type="text" name="q" /> 
    <button type="submit">search</button> 
</form>

就搞定了!不論使用者 submit 哪一個 form,都可以被 IceCreamStoreListView 處理。

上面的 pattern 有一個先決條件:StoreFlavor 要被搜尋的欄位都必須叫做 title。你可能不想要,甚至不能有這個限制。所以我們要把這個欄位名變成動態的,可以讓我們自己指定。

首先我們要改寫 TitleSearchMixin。因為現在不一定只能搜尋 title 了,順便也改個名吧(因為名詞需要多多重構!):

class FieldSearchMixin(object): 
    def get_queryset(self): 
        queryset = super(TitleSearchMixin, self).get_queryset() 
        q = self.request.GET.get("q") 
        if q: 
            filters = {field_name + '__icontains': q}
            return queryset.filter(**filters) 
        return queryset

這樣只要換掉 field_name,就可以搜尋任何你想要的欄位。

下一步就是要想辦法拿到 field_name 的值。這有很多種方法,最無腦的是加一個 form field:

<form action="" method="GET"> 
    <input type="text" name="q" /> 
    <input type="hidden" name="field_name" />
    <button type="submit">search</button> 
</form>
class FieldSearchMixin(object): 
    def get_queryset(self): 
       field_name = self.request.GET.get("field_name")
       # 後面省略

但是這非常不安全,因為你永遠都不該相信 user input,更何況這邊是用 GET,在網址列都被看光光了。

好一點的寫法是放在 view class 裡面:

class FieldSearchMixin(object): 
    def get_queryset(self): 
       field_name = self.field_name_to_search
class IceCreamFlavorListView(TitleSearchMixin, ListView): 
    model = Flavor
    field_name_to_search = 'title'

但是仍然有點問題,因為 class attribute 有被其他人複寫的風險;這個欄位名明明就只有在 get_queryset 用到,這樣寫就...就少了一個 class attribute name 可以用,很不方便。不論如何,變數的 scope 本來就應該越小越好。最理想的方法應該是使用 factory method pattern

def field_search_mixin_factory(field_name):

    class FieldSearchMixin(object): 
        def get_queryset(self): 
            queryset = super(TitleSearchMixin, self).get_queryset() 
            q = self.request.GET.get("q") 
            if q: 
                filters = {field_name + '__icontains': q}
                return queryset.filter(**filters) 
            return queryset
            
    return FieldSearchMixin

然後這樣用

class IceCreamFlavorListView(field_search_mixin_factory('title'), ListView): 
    model = Flavor

很神奇嗎?Python 就是這麼神奇