iT邦幫忙

0

Django學習紀錄 17.視圖類別

之前有在用Django寫一些小網站,現在暑假想說再來複習一下之前買的這本書
https://ithelp.ithome.com.tw/upload/images/20190724/20118889bj9fH1vhuR.jpg
於是我就把它寫成一系列的文章,也方便查語法
而且因為這本書大概是2014年出的,如今Django也已經出到2.多版
有些內容也變得不再支援或適用,而且語法或許也改變了
所以我會以最新版的Python和Django來修正這本書的內容跟程式碼

目錄:django系列文章-Django學習紀錄

17. 視圖類別

17.1 什麼是視圖類別(Class-based View)?

將原本的here視圖函式
mysite/mysite/views.py

def here(request):
    return HttpResponse('媽,我在這!')

改為HereView視圖類別

from django.views.generic.base import View

class HereView(View):
    
    def get(self, request):
        return HttpResponse('媽,我在這!')

首先django.views.generic.base.View這是所有視圖類別的最基礎類別,任何視圖類別都要繼承它
接著撰寫(其實是覆寫)get方法,此方法有兩個參數,第一個放self,第二個放HttpRequest物件
此方法的內容跟原來的視圖函式一模一樣,也可以用render_to_responserender
urls.py設置
原本的

import mysite.views

urlpatterns = [
    ...
    path('here/', mysite.views.here),
    ...
]

改為

import mysite.views

urlpatterns = [
    ...
    path('here/', mysite.views.HereView.as_view()),
    ...
]

原本是要呼叫視圖函式的名稱,現在改為要呼叫HereView視圖類別的as_view方法
as_view是View類別以及所有繼承此類別的衍生類別都會有的類別方法
我們可以想像它是視圖類別的進入點
在URL配置檔中,一律要呼叫對應的視圖類別的as_view方法
類別方法是屬於類別的方法,不需要實體化的物件實例就可以呼叫和使用
但物件方法只能由實例來呼叫
as_view方法會利用View類別(以及所有繼承此類別的衍生類別)中的物件方法dispatch方法來選擇適當的處理函式,例如當我們使用Http協定去GET某個URL頁面時,dispatch會幫我們選擇使用get方法來回應
整理一下View類別(以及所有繼承此類別的衍生類別)中重要的方法及屬性:

名稱 型態 說明
http_method_names 屬性 定義了這個視圖類別可以接收、處理的Http協定,預設值為:['get', 'post', 'put', 'delete', 'head', 'options', 'trace']
as_view() 類別方法 可視為view的進入點,在urls.py中視圖類別需呼叫這個方法,會將進來的請求轉至dispatch處理
dispatch 物件方法 這個方法會將核可的http請求(協定有列在http_method_names中)轉發至相對應的函式,如使用者發了一個GET,如果GET請求在http_method_names中,那麼便會將request轉發至這個視圖類別的get(self, request, **kwargs)方法處理

所以我們能夠透過設定http_method_names來指定允許接收和處理的Http協定

17.2 視圖類別的種類與使用

python的繼承

class BaseClass:

    def test(self):
        print("BaseClass")

class MyClass(BaseClass): # MyClass繼承了BaseClass
    pass

mc = MyClass()
mc.test()

結果:

BaseClass

python也支援多重繼承

class BaseClass:

    def test(self):
        print("BaseClass")

class Mixin1:

    def ability1(self):
        print("Get Ability1!")

    def test(self):
        print("Mixin1")

class Mixin2:

    def ability2(self):
        print("Get Ability2!")

    def test(self):
        print("Mixin2")

class MyClass(Mixin2, Mixin1, BaseClass):
    pass

mc = MyClass()
mc.ability1()
mc.ability2()
mc.test()

結果:

Get Ability1!
Get Ability2!
Mixin2

在python中,多重繼承由右邊繼承至左邊
所以這就是為什麼mc.test()的結果是印出Mixin2
這個可能跟直覺相反,當你看到class MyClass(Mixin2, Mixin1, BaseClass)的時候
可以想像它的演變順序從左到右依序是由後代到祖先:
MyClass < Mixin2 < Mixin1 < BaseClass
像Mixin1與Mixin2這種用以提供衍生類別藉由多重繼承來匯聚多個類別能力的基礎類別,叫做Mixin,因為它會Mix in to它的衍生類別
透過django.views.generic模組下的各種Mixin
能夠讓View類別變為擁有更多功能的新視圖類別
像是內建的TemplateViewListViewFormView都是繼承了不同Mixin實作的功能,而發展成不同的視圖類別,這些視圖類別皆放置在django.views.generic下,如果想找內建的視圖類別和Mixin只要到這裡找即可
接下來我們要利用這些視圖類別來改寫之前的視圖函式

分類 說明
Generic View 一般、沒有特殊需求可使用視圖類別
Display View 與模型合作的視圖,經過簡單設定便可展示資料庫內容
Edit View 修改資料庫相關的視圖

17.2.1 Generic View

比較重要的Generic View有兩個:View和TemplateView
View剛才我們看過了

TemplateView

TemplateView可以根據參數給定的模板以及Context參數來填寫並回應該模板
TemplateViewt除了繼承View這個最基礎的類別之外
也繼承了兩個Mixin:ContextMixin和TemplateResponseMixin
其實幾乎其他所有的內建視圖類別都有繼承這兩個Mixin,因為這兩個Mixin實作了跟模板使用有關的功能,而模板的使用幾乎是必備的

Mixin 說明
ContextMixin 這個類別只有一個方法get_context_data,它會回傳一個字典,之後要呈現在網頁上的變量都可以藉由呼叫或覆寫這個函式來定義
TemplateResponseMixin 這個類別會依據被覆寫的template_name屬性來取得模板,並處理、回傳一個HttpResponse

所以我們將使用ContextMixin來設置變量,用TemplateResponseMixin來指定模板
而TemplateView原本的get方法已經將填寫與回應的動作建置好了,如果沒有特殊需求可以不用去覆寫它
將原本的index視圖函式
mysite/mysite/views.py

def index(request):
    return render(request, 'index.html')

改為

from django.views.generic.base import View, TemplateView

class IndexView(TemplateView):
    template_name = 'index.html'

template_name填入要使用的模板名稱
設定了這個變數後,TemplateResponseMixin會自動幫我們取得指定的模板
接著要設定模板中的request變量
利用TemplateView中的ContextMixin來設置,但我們就必須要覆寫get方法了

class IndexView(TemplateView):
    template_name = 'index.html'

    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        context['request'] = request
        return self.render_to_response(context)

讓我們的get方法多出*args, **kwargs兩個參數,這是一個比較安全的寫法,這樣做可以保持這個函式的彈性
接著利用ContextMixin提供的get_context_data方法來取得context
並且幫這個context多設置request這個變量
最後利用TemplateResponseMixin提供的render_to_response來填寫模板並回應
但這個方法必須要重新覆寫get方法,有點麻煩,我們可以這樣做:
settings.py中

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request', # <-加入這行
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

在處理器中加入request處理器的模組,如此一來request變量將會自動加入context中
這樣我們就不需要覆寫get方法了,因此可以把get方法整個拿掉也不會受影響
接下來設置urls.py

path('index/', mysite.views.IndexView.as_view())

由於這個頁面只有載入模板,所以可以使用更簡潔的方式

from django.views.generic.base import TemplateView

urlpatterns = [
    ...
    path('index/', TemplateView.as_view(template_name='index.html')),
    ...
]

除了request這種特殊的變量需要藉由覆寫get方法才能拿到之外
如果要設置其他變量其實只要覆寫get_context_data就好了
假設我們要加入time這個變量,而且request已經利用處理器設置好了

from django.utils import timezone

class IndexView(TemplateView):
    template_name = 'index.html'

    def get_context_data(self, **kwargs):
        # 取得字典型態的Context
        context = super(IndexView, self).get_context_data(**kwargs)
        # 加入我們額外想要的時間參數
        context["time"] = timezone.now()
        return context

藉由super呼叫IndexView基礎類別TemplateView的get_context_data方法來取得原始的context,接著為context設置time變量,最後回傳即可
如此一來,IndexView會使用預設的get方法完成所有的動作
注意一下,如果用的是python3,則super(IndexView, self)只需要打成super()就可以了

17.2.2 Display View

視圖類別DisplayView皆有一個必須賦值的屬性model
這個屬性用來定義所要展示的模型(資料庫表單)
因此,DisplayView常用來與模型資料庫一起工作
接下來要介紹其中兩個視圖類別:ListView和DetailView

使用ListView

ListView可以展示某個資料庫模型裡的資料,有點像內建查詢功能一樣
將原本的
mysite/restaurants/views.py

from django.contrib.auth.decorators import login_required
from restaurants.models import Restaurant, Food

@login_required
def list_restaurants(request):
    restaurants = Restaurant.objects.all()
    return render(request, 'restaurants_list.html', locals())

改為

from django.views.generic.list import ListView
from restaurants.models import Restaurant, Food

class RestaurantsView(ListView):
    model = Restaurant
    template_name = 'restaurants_list.html'
    context_object_name = 'restaurants'

首先要指定一個要展示的資料庫模型給model
我們想展示Restaurant的資料,所以填入Restaurant
接著跟之前一樣使用template_name來指定使用的模板
如果只寫出模板名稱,那此類別所使用的TemplateResponseMixin會到每個應用下的template目錄去尋找該模板,所以這邊填入restaurants_list.html即可
如果不設定template_name,那Django會去專案目錄下的templates中尋找名為"應用名稱/模型名稱_list.html"的模板,比如說這裡我們不設置的話,就必須在mysite/templates中放置一個叫做restaurants/restaurant_list.html的模板
context_object_name用來為我們取出來的變量(一個模型資料的清單)取名
如果不設置此變數,則預設取出的資料清單會以object_list為名
比如說模板中的{% for r in restaurants %}就須改為{% for r in object_list %}
除此之外ListView還可以設置queryset,此變數會決定查詢的方式和結果
預設的querysetmodel.objects.all(),我們可以使用任何合法的模型查詢手段來改變它,比如說我們想要依名稱排序

queryset = Restaurant.objects.order_by("-name")

還記得原本這個頁面需要登入才能瀏覽
我們可以直接在urls.py中使用裝飾器

path('restaurants_list/', login_required(restaurants.views.RestaurantsView.as_view()))

或是另一種方式,裝飾dispatch方法
由於dispatch會將請求轉發到各對應的函式
所以dispatch被修飾過的視圖類別,它所有支援的Http協定方法,都需要登入才能操作了

from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.views.generic.list import ListView
from restaurants.models import Restaurant, Food

class RestaurantsView(ListView):
    model = Restaurant
    template_name = 'restaurants_list.html'
    context_object_name = 'restaurants'

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(RestaurantsView, self).dispatch(request, *args, **kwargs)

method_decorator這個裝飾器負責引入原本修飾函式的裝飾器給類別中的方法使用

使用DetailView

DetailView則是用在單一筆資料的檢視頁面,如我們之前做的菜單,因為這個頁面呈現了其中一間餐廳的詳細資料,所以適合用DetailView來寫
之前使用的方法一配置

path('menu/', menu)
from django.shortcuts import render_to_response
from restaurants.models import Restaurant
from django.http import HttpResponseRedirect

def menu(request):
    if 'id' in request.GET and request.GET['id'] != '':
        restaurant = Restaurant.objects.get(id=request.GET['id'])
        return render_to_response('menu.html', locals())
    else:
        return HttpResponseRedirect("/restaurants_list/")

又或者採用方法三

re_path(r'menu/(\d{1,5})', menu),
def menu(request, id):
    if id:
        restaurant = Restaurant.objects.get(id=id)
        return render_to_response('menu.html', locals())
    else:
        return HttpResponseRedirect("/restaurants_list/")

不論使用哪一種配置都可以改寫成這樣

from django.views.generic.detail import DetailView

class MenuView(DetailView):
    model = Restaurant
    template_name = 'menu.html'
    context_object_name = 'restaurant'

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(MenuView, self).dispatch(request, *args, **kwargs)

DetailView中的model代表的是我們要檢視的某一筆資料位於哪一個模型
其他設置和ListView都是一樣的
事實上我們可以用ListView來完成這件事,只要調整我們的queryset,不過DetailView提供了更特定的功能可以使用
使用DetailView就不必處理GET方法的判定,也不用處理id的取得,更不用親自去模型中查詢出要顯示的那筆資料
不過我們卻完全沒有告訴視圖我們要的是哪一筆資料,沒關係
因為DetailView預設會向URL討一個主鍵參數(也就是我們的餐廳id),所以它內部預設會使用取得的主鍵來查詢出該筆資料,只是這個主鍵會以關鍵字參數的方式取得(而且預設名字為pk)
所以我們必須修改URL配置

re_path(r'menu/(?P<pk>\d+)', restaurants.views.MenuView.as_view())

如果不想用pk這個名字,只要覆寫pk_url_kwarg屬性即可

class MenuView(DetailView):
    model = Restaurant
    template_name = 'menu.html'
    context_object_name = 'restaurant'
    pk_url_kwarg = 'id'

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(MenuView, self).dispatch(request, *args, **kwargs)

順便一提,原本的函式中有重導的動作
在DetailView也可以藉由覆寫get方法來完成

from django.http import HttpResponseRedirect, Http404

class MenuView(DetailView):
    model = Restaurant
    template_name = 'menu.html'
    context_object_name = 'restaurant'

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(MenuView, self).dispatch(request, *args, **kwargs)

    def get(self, request, pk, *args, **kwargs):
        try:
            return super(MenuView, self).get(self, request, pk=pk, *args, **kwargs)
        except Http404:
            return HttpResponseRedirect('/restaurants_list/')

一旦餐廳的id不是合法的,將會取不到資料,此時會導致Http404的錯誤,所以利用try/catch來接這個錯誤

17.3 EditView

這類的視圖是要用來與使用者互動的,它能透過發送表單,新增、修改、刪除資料庫的內容,我們來介紹FormView

17.3.1 使用FormView

將原本的

def comment(request, id):
    if id:
        r = Restaurant.objects.get(id=id)
    else:
        return HttpResponseRedirect("/restaurants_list/")
    if request.POST:
        f = CommentForm(request.POST)
        if f.is_valid():
            visitor = f.cleaned_data['visitor']
            content = f.cleaned_data['content']
            email = f.cleaned_data['email']
            date_time = timezone.localtime(timezone.now())  # 擷取現在時間
            Comment.objects.create(visitor=visitor, email=email, content=content, date_time=date_time, restaurant=r)
            visitor, content, email = ('', '', '')
            f = CommentForm(initial={'content': '我沒意見'})
    else:
        f = CommentForm(initial={'content': '我沒意見'})
    return render(request, 'comments.html', locals())

修改為

from restaurants.forms import CommentForm
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView

class CommentView(FormView, SingleObjectMixin):
    form_class = CommentForm
    template_name = 'comments.html'
    success_url = '/comment/'
    initial = {'content': '我沒意見'}

    def form_valid(self, form):
        Comment.objects.create(
            visitor=form.cleaned_data['visitor'],
            email=form.cleaned_data['email'],
            content=form.cleaned_data['content'],
            date_time=timezone.localtime(timezone.now()),
            restaurant=self.get_object(),
        )
        return self.render_to_response(self.get_context_data(
            form=self.form_class(initial=self.initial)
        ))

首先form_class設定要使用的表單類別,template_name跟之前一樣
接著success_url指定一個合法的URL路徑,預設如果表單驗證正確,就會在表單處理完後跳轉至此頁面
initial可以依表單欄位名稱提供初始值
最後form_valid方法會在使用者提交表單且表單欄位皆驗證通過時被呼叫(如果使用者是第一次進入該頁面,則會由其他方法處理),不過此方法有預設的處理手段,如果我們不覆寫它的話,它就只會讓頁面跳轉至success_url,因為我們要依據表單來建立Comment資料,所以我們覆寫此方法
form_valid吃兩個參數,第一個是self,第二個是已經驗證通過的表單模型物件form
我們從form中取出各欄位的cleaned_data並呼叫Comment物件管理器的create方法來新增一筆評論
如果下一步是要跳轉至成功頁面的話,可以簡單呼叫CommentView的父類別(FormView)的form_valid函式,畢竟它預設會處理跳轉的動作,如下:

...
    return super(CommentView, self).form_valid(form)
...

但在這裡我們想要回到原本的頁面,並重新生成一個未使用的表單,所以我們不回傳父類別的方法,而自行回傳一個有新的表單的頁面,所以在函式結尾的地方使用self.render_to_response來回應一個新頁面,我們不需提供模板(因為已經設定過了),只需要提供表單模型的變量

context = self.get_context_data()
context['form'] = self.form_class(initial=self.initial)
return self.render_to_response(context)

為了簡化可以這樣做:

return self.render_to_response(self.get_context_data(form=self.form_class(initial=self.initial)))

所以這樣就可以提供form變量給模板,記得要將comments.html中的f變量改為form
而且在comments.html中,我們還有r變量未提供

from restaurants.forms import CommentForm
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView

class CommentView(FormView, SingleObjectMixin):
    form_class = CommentForm
    template_name = 'comments.html'
    success_url = '/comment/'
    initial = {'content': '我沒意見'}
    # 加入以下兩行
    model = Restaurant
    context_object_name = 'r'

    def form_valid(self, form):
        Comment.objects.create(
            visitor=form.cleaned_data['visitor'],
            email=form.cleaned_data['email'],
            content=form.cleaned_data['content'],
            date_time=timezone.localtime(timezone.now()),
            restaurant=self.get_object(),
        )
        return self.render_to_response(self.get_context_data(
            form=self.form_class(initial=self.initial)
        ))

    def get_context_data(self, **kwargs):
        self.object = self.get_object()
        return super(CommentView, self).get_context_data(object=self.object, **kwargs)

增加兩個變數modelcontext_object_name,它們都是SingleObjectMixin提供的變數,SingleObjectMixin可以幫助我們從某資料庫模型中取出一筆資料,並以變量的方式提供給模板,透過設定model就可指定資料庫模型,透過context_object_name就可指定變量名稱
接著要覆寫get_context_data方法,利用SingleObjectMixin的get_object()方法來取得資料物件(此方法會自動擷取URL參數),然後將此變量加入context
SingleObjectMixin跟DetailView的功能很像就是因為DetailView就是靠SingleObjectMixin組裝起來的
然後要記得改成使用關鍵字參數

re_path(r'comment/(?P<pk>\d+)', restaurants.views.CommentView.as_view())

別忘了之前我們也把評論頁面設為有權限設定,同樣也是透過裝飾dispatch達成

from django.utils.decorators import method_decorator
from restaurants.forms import CommentForm
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView

def user_can_comment(user):
    return user.is_authenticated and user.has_perm('restaurants.can_comment')

class CommentView(FormView, SingleObjectMixin):
    form_class = CommentForm
    template_name = 'comments.html'
    success_url = '/comment/'
    initial = {'content': '我沒意見'}
    model = Restaurant
    context_object_name = 'r'

    def form_valid(self, form):
        Comment.objects.create(
            visitor=form.cleaned_data['visitor'],
            email=form.cleaned_data['email'],
            content=form.cleaned_data['content'],
            date_time=timezone.localtime(timezone.now()),
            restaurant=self.get_object(),
        )
        return self.render_to_response(self.get_context_data(
            form=self.form_class(initial=self.initial)
        ))

    def get_context_data(self, **kwargs):
        self.object = self.get_object()
        return super(CommentView, self).get_context_data(object=self.object, **kwargs)

    @method_decorator(user_passes_test(user_can_comment, login_url='/accounts/login/'))
    def dispatch(self, request, *args, **kwargs):
        return super(CommentView, self).dispatch(request, *args, **kwargs)

17.4 視圖類別的優勢

舉例,TemplateView無法輕易設置context,不過我們可以這樣做:

class AdvTemplateView(TemplateView):
    
    def get(self, request, *args, **kwargs):
        return self.render_to_response(self.context)

透過繼承以及覆寫方法來產生一個更符合我們要求的新類別
之後如果遇到需要提供變量的情況,我們可以繼承這個類別:

class MyView(AdvTemplateView):
    template_name = 'index.html'
    # 利用一個tricky的技巧取得context
    context = AdvTemplateView().get_context_data()
    context['hello'] = '123' # 提供了hello變量

還有像是我們常常需要判斷是GET還是POST方法,不過只要透過dispatch方法就能夠達到簡化代碼的目的

上一篇:Django學習紀錄 16.URL配置與視圖進階技巧

下一篇:Django學習紀錄 18.資料庫與模型進階技巧


尚未有邦友留言

立即登入留言