跳轉到

Class Based View

開始之前

任務目標

在這個任務中,你將學習:

  • 了解 Class Based View (CBV) 的概念與優勢
  • 將 DetailView 從 FBV 改寫為 CBV
  • 將 ListView 從 FBV 改寫為 CBV
  • 將 CreateView 從 FBV 改寫為 CBV
  • 將 UpdateView 從 FBV 改寫為 CBV
  • 將 DeleteView 從 FBV 改寫為 CBV
  • 使用 Mixin 加入權限控制
  • 了解 CBV 的常用客製化方法

什麼是 Class Based View

在前面的任務中,我們一直使用的是 Function Based View (FBV),也就是用函式來處理 HTTP 請求。而 Class Based View (CBV) 則是使用類別來處理請求。

FBV 的特性

讓我們回顧一下目前的 article_detail view:

def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})

FBV 的特點:

  • 直觀易懂,適合簡單的邏輯
  • 每個 view 都是一個獨立的函式
  • 如果有重複的邏輯,需要自己抽取成共用函式

CBV 的優勢

CBV 透過類別的繼承和組合,提供以下優勢:

  1. 可重用性:透過繼承 Django 提供的 Generic Views,大幅減少重複的程式碼
  2. 易於擴展:可以透過覆寫方法來客製化行為
  3. Mixin 組合:可以透過多重繼承組合多個 Mixin,如權限控制、分頁等功能

何時使用 CBV

CBV 特別適合以下情境:

  • CRUD 操作:列表、詳細、新增、編輯、刪除等標準化操作
  • 表單處理:有固定的表單處理流程
  • 需要繼承和擴展:多個 view 有相似的邏輯

而 FBV 則適合:

  • 邏輯簡單、不需要重用的 view
  • 需要高度客製化的特殊邏輯
  • 團隊成員對 OOP 不熟悉的情況

Django Generic Views 概覽

Django 提供了許多內建的 Generic Views,讓我們可以快速實作常見的功能。

常用的 Generic Views

  • TemplateView:單純顯示模板,不涉及資料庫操作
  • ListView:顯示資料列表(如文章列表)
  • DetailView:顯示單筆資料的詳細內容(如文章詳細頁面)
  • CreateView:建立新資料(如新增文章)
  • UpdateView:更新資料(如編輯文章)
  • DeleteView:刪除資料(如刪除文章)

接下來,我們將逐步把 blog app 中的 FBV 改寫為 CBV。

改寫 DetailView

讓我們從最簡單的 article_detail 開始改寫。

參考文件

想了解更多 DetailView 的細節,可以參考 Django 官方文件

目前的 article_detail

目前 blog/views.py 中的 article_detail 是這樣的:

blog/views.py
def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})

這個 view 的工作很簡單:

  1. 根據 article_id 取得文章
  2. 如果找不到就回傳 404
  3. 渲染模板並傳入 article 變數

改寫為 CBV

Django 的 DetailView 已經幫我們處理好這些邏輯了,讓我們來改寫:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import DetailView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    filter_ = ArticleFilter(
        request.GET or None,
        queryset=Article.objects.select_related("author").prefetch_related("tags"),
    )
    return render(request, "blog/article_list.html", {"filter": filter_})


class ArticleDetailView(DetailView):  # (1)!
    model = Article  # (2)!
    pk_url_kwarg = "article_id"  # (3)!


@permission_required("blog.add_article", raise_exception=True)
def article_create(request):
    form = ArticleForm(request.POST or None, request.FILES or None)
    if form.is_valid():
        article = form.save(commit=False)
        article.created_by = request.user
        article.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 繼承 DetailView 類別
  2. 指定要使用的 Model
  3. 設定 pk_url_kwargarticle_id,讓 URL 參數保持使用 article_id

是不是簡潔多了?DetailView 自動幫我們處理了:

  • 根據 URL 參數取得物件
  • 找不到時回傳 404
  • 渲染模板(預設為 blog/article_detail.html
  • 傳入 context 變數(預設為 objectarticle

DetailView 的預設行為

DetailView 會自動根據 Model 名稱來決定:

  • template_name: <app_label>/<model_name>_detail.htmlblog/article_detail.html
  • context_object_name: object<model_name>objectarticle
  • pk_url_kwarg: pk(從 URL 取得物件的主鍵參數名稱,預設為 pk

因為我們的設定都符合預設值(除了 pk_url_kwarg),所以不需要明確指定 template_namecontext_object_name

但我們還沒有加入查詢優化(select_relatedprefetch_related),稍後會說明如何加入。

調整 URL 設定

現在需要調整 urls.py 來使用 CBV:

blog/urls.py
from django.urls import path

from blog import views

app_name = "blog"

urlpatterns = [
    path("articles/", views.article_list, name="article_list"),
    path("articles/create/", views.article_create, name="article_create"),
    path("articles/<int:article_id>/", views.ArticleDetailView.as_view(), name="article_detail"),  # (1)!
    path("articles/<int:article_id>/edit/", views.article_edit, name="article_edit"),
    path("articles/<int:article_id>/delete/", views.article_delete, name="article_delete"),
]
  1. 使用 .as_view() 方法將 CBV 轉換為 view 函式

重點說明:

  • 使用 .as_view() 方法將 CBV 轉換為可以處理請求的函式
  • URL 參數保持使用 article_id,並在 view 中透過 pk_url_kwarg 對應
  • DetailView 預設會從 URL 參數中找 pkslug 來查詢物件

加入查詢優化

現在讓我們加入查詢優化,使用 queryset 屬性:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import DetailView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    filter_ = ArticleFilter(
        request.GET or None,
        queryset=Article.objects.select_related("author").prefetch_related("tags"),
    )
    return render(request, "blog/article_list.html", {"filter": filter_})


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


@permission_required("blog.add_article", raise_exception=True)
def article_create(request):
    form = ArticleForm(request.POST or None, request.FILES or None)
    if form.is_valid():
        article = form.save(commit=False)
        article.created_by = request.user
        article.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})

queryset 是一個類別屬性,用來定義要查詢的資料集。如果你需要更複雜的邏輯(例如根據 request 動態決定查詢條件),可以覆寫 get_queryset() 方法。但對於簡單的查詢優化,使用 queryset 屬性就足夠了。

如果 view 中有定義 querysetmodel 就可以被省略

改寫 ListView

接下來改寫 article_list

參考文件

想了解更多 ListView 的細節,可以參考 Django 官方文件

改寫 article_list

先來看看基本的 ListView 用法:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import DetailView, ListView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(ListView):  # (1)!
    queryset = Article.objects.select_related("author").prefetch_related("tags")  # (2)!


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


@permission_required("blog.add_article", raise_exception=True)
def article_create(request):
    form = ArticleForm(request.POST or None, request.FILES or None)
    if form.is_valid():
        article = form.save(commit=False)
        article.created_by = request.user
        article.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 繼承 ListView 類別
  2. 使用 queryset 屬性加入查詢優化(有 queryset 就不需要定義 model

ListViewDetailView 一樣,會自動根據 Model 名稱決定預設值:

  • template_name: blog/article_list.html
  • context_object_name: object_listarticle_list

因為符合預設值,所以不需要明確指定。

現在更新 urls.py

blog/urls.py
from django.urls import path

from blog import views

app_name = "blog"

urlpatterns = [
    path("articles/", views.ArticleListView.as_view(), name="article_list"),
    path("articles/create/", views.article_create, name="article_create"),
    path("articles/<int:article_id>/", views.ArticleDetailView.as_view(), name="article_detail"),
    path("articles/<int:article_id>/edit/", views.article_edit, name="article_edit"),
    path("articles/<int:article_id>/delete/", views.article_delete, name="article_delete"),
]

整合 django-filter

我們的文章列表需要整合 django-filter 來實現篩選功能。有兩種方式可以整合:

方式一:在 ListView 中手動整合

要在 ListView 中使用 django-filter,我們需要透過 get_context_data() 方法:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import DetailView, ListView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(ListView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")

    def get_queryset(self):  # (1)!
        queryset = super().get_queryset()
        self.filter = ArticleFilter(self.request.GET or None, queryset=queryset)
        return self.filter.qs

    def get_context_data(self, **kwargs):  # (2)!
        context = super().get_context_data(**kwargs)
        context["filter"] = self.filter
        return context


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


@permission_required("blog.add_article", raise_exception=True)
def article_create(request):
    form = ArticleForm(request.POST or None, request.FILES or None)
    if form.is_valid():
        article = form.save(commit=False)
        article.created_by = request.user
        article.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 覆寫 get_queryset(),先建立 FilterSet 並保存到 self.filter,然後回傳篩選後的 queryset (self.filter.qs)
  2. 覆寫 get_context_data(),將 self.filter 加入 context 中

什麼是 get_context_data()

get_context_data() 是用來準備要傳遞給模板的 context 變數的方法。當你需要在模板中使用額外的資料時,就可以透過這個方法加入。

為什麼要改寫 get_queryset()

你可能會思考我們在 Template 中是使用 filter.qs 這樣為什麼還要改寫 get_queryset() 呢?

因為 ListView 會使用篩選後的 queryset 來進行後續的處理(例如分頁)。同時我們在使用 ListView 時也會預期在 template 中可以使用 object_list 來取得文章列表。所以就算不使用分頁等功能,我們還是要確保 object_list 是篩選後的文章列表,這樣比較符合 ListView 的預設行為。

方式二:使用 django-filter 的 FilterView

django-filter 提供了 FilterView,它已經整合好篩選功能,使用起來更簡單。詳細資訊可參考 django-filter 官方文件

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import DetailView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):  # (1)!
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter  # (2)!
    template_name = "blog/article_list.html"  # (3)!


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


@permission_required("blog.add_article", raise_exception=True)
def article_create(request):
    form = ArticleForm(request.POST or None, request.FILES or None)
    if form.is_valid():
        article = form.save(commit=False)
        article.created_by = request.user
        article.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 繼承 FilterView 而不是 ListView
  2. 指定要使用的 FilterSet 類別
  3. 指定要使用的 template,FilterView 預設尋找 _filter 的 template。在這個案例中會是 blog/article_filter.html

FilterView 的特點:

  • 自動處理 GET 參數並套用篩選
  • 自動將 filter 物件傳入 template context
  • 程式碼更簡潔,不需要覆寫 get_context_data()
  • FilterView 預設尋找 <app_label>/<model_name>_filter.htmlblog/article_filter.html)樣板,如果要使用的名稱不一樣則需要特別指定

選擇哪種方式?

  • 方式一(手動整合):適合需要更多客製化的情況,或是想要完全掌控篩選邏輯
  • 方式二(FilterView):適合標準的篩選需求,程式碼更簡潔

在本教學中,我們將使用方式二(FilterView),因為它已經整合好篩選功能,程式碼更簡潔且易於維護。

改寫 CreateView

接下來處理表單相關的 view,從 article_create 開始。

參考文件

想了解更多 CreateView 的細節,可以參考 Django 官方文件

改寫 article_create

CreateView 用來處理新增資料的邏輯:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import CreateView, DetailView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(CreateView):  # (1)!
    model = Article
    form_class = ArticleForm  # (2)!
    template_name = "blog/article_create.html"  # (3)!


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 繼承 CreateView 類別
  2. 指定要使用的 Form 類別
  3. 指定模板名稱(預設是 blog/article_form.html,但我們使用 article_create.html

CreateView 自動處理了:

  • GET 請求時顯示空白表單
  • POST 請求時驗證並儲存資料
  • 驗證失敗時重新顯示表單(含錯誤訊息)

但我們還需要:

  1. 設定 created_by 欄位
  2. 加入成功訊息
  3. 設定成功後的導向

繼續修改 View 之前修改之前,先來更新 urls.py

blog/urls.py
from django.urls import path

from blog import views

app_name = "blog"

urlpatterns = [
    path("articles/", views.ArticleListView.as_view(), name="article_list"),
    path("articles/create/", views.ArticleCreateView.as_view(), name="article_create"),
    path("articles/<int:article_id>/", views.ArticleDetailView.as_view(), name="article_detail"),
    path("articles/<int:article_id>/edit/", views.article_edit, name="article_edit"),
    path("articles/<int:article_id>/delete/", views.article_delete, name="article_delete"),
]

設定 created_by

透過覆寫 form_valid() 方法,可以在表單驗證通過後、儲存前進行額外的處理:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import CreateView, DetailView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"

    def form_valid(self, form):  # (1)!
        self.object = form.save(commit=False)  # (2)!
        self.object.created_by = self.request.user  # (3)!
        self.object.save()
        form.save_m2m()  # (4)!
        return redirect(self.get_success_url())  # (5)!


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 覆寫 form_valid() 方法
  2. 將表單資料保存到 self.object,但先不寫入資料庫
  3. 設定 created_by 欄位
  4. 處理 many-to-many 關係(如果有的話)
  5. 手動重定向到成功頁面

form_valid() 會在表單驗證通過後被呼叫,這時候我們可以在儲存前做額外的處理。

加入成功訊息

現在加入成功訊息:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import CreateView, DetailView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")  # (1)!
        return redirect(self.get_success_url())


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. form_valid() 中加入成功訊息

現在我們有了成功訊息,但還需要設定成功後要導向的位置。CreateView 預設會在儲存成功後導向到物件的詳細頁面,但前提是 Model 必須定義 get_absolute_url() 方法。

使用 Model 的 get_absolute_url()

接著我們要決定當表單成功被儲存後該跳轉到哪,我們可以透過在 View 中定義 success_url 屬性

class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"
    success_url = reverse_lazy("blog:article_list")

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")
        return redirect(self.get_success_url())

或是在需要使用建立完的物件可以透過 get_success_url() 方法

class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")
        return redirect(self.get_success_url())

    def get_success_url(self):
        return reverse("blog:article_detail", kwargs={"article_id": self.object.pk})

不過,每次都要寫 get_success_url() 有點麻煩。Django 有個更好的做法:在 Model 中定義 get_absolute_url() 方法。這樣不管多少 view 使用到這個 Model 都可以使用同一個邏輯。

讓我們在 Article model 中加入這個方法:

blog/models.py
from django.conf import settings
from django.db import models
from django.templatetags.static import static
from django.urls import reverse

from blog.validators import validate_image_dimensions, validate_image_extension, validate_image_size


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover_image = models.ImageField(
        upload_to="articles/covers/",
        blank=True,
        null=True,
        validators=[
            validate_image_size,
            validate_image_extension,
            validate_image_dimensions,
        ],
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    def __str__(self):
        return self.title

    def get_absolute_url(self):  # (1)!
        return reverse("blog:article_detail", kwargs={"article_id": self.pk})

    def get_cover_image_url(self):
        if self.cover_image:
            return self.cover_image.url

        return static("blog/images/default-cover.jpg")
  1. 定義 get_absolute_url() 方法,回傳該物件的詳細頁面 URL

有了 get_absolute_url() 之後,Django 的 Generic Views 會自動使用它作為 success_url,不需要額外設定。

get_absolute_url() 的好處

使用 get_absolute_url() 的好處:

  • 一致性:整個專案中,只要有 Article 物件就能取得它的 URL
  • 簡潔:不需要在每個 View 中重複寫 get_success_url()
  • 可重用:可以在 template 中使用 {{ article.get_absolute_url }}
  • 最佳實踐:這是 Django 官方推薦的做法

改寫 UpdateView

UpdateViewCreateView 非常相似,主要差別在於它是編輯現有的資料。

參考文件

想了解更多 UpdateView 的細節,可以參考 Django 官方文件

改寫 article_edit

讓我們改寫 article_edit

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import CreateView, DetailView, UpdateView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")
        return redirect(self.get_success_url())


class ArticleUpdateView(UpdateView):  # (1)!
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_edit.html"  # (2)!
    pk_url_kwarg = "article_id"  # (3)!

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, f"文章「{self.object.title}」已成功更新。")  # (4)!
        return redirect(self.get_success_url())


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 繼承 UpdateView 類別
  2. 指定模板名稱(預設是 blog/article_form.html,但我們使用 article_edit.html
  3. 設定 pk_url_kwargarticle_id,讓 UpdateView 知道要從 URL 的 article_id 參數取得物件
  4. form_valid() 中加入成功訊息

有更簡潔的方式!

你可能注意到在 form_valid() 中手動加入 messages.success() 有點重複。別擔心,在後面的「SuccessMessageMixin」章節中,我們會學到使用 SuccessMessageMixin 來簡化這個流程。

但在那之前,讓我們先理解基本的 form_valid() 覆寫方式,這對於理解 CBV 的運作機制很重要。

UpdateViewCreateView 的主要差異:

  • UpdateView 會自動取得現有的物件並傳入 form
  • 不需要設定 created_by,因為這是編輯而非新增
  • 需要指定 pk_url_kwarg 來對應 URL 參數名稱(因為我們的 URL 使用 article_id 而不是預設的 pk
  • CreateView 一樣,會自動使用 model 的 get_absolute_url() 作為 success_url

現在更新 urls.py

blog/urls.py
from django.urls import path

from blog import views

app_name = "blog"

urlpatterns = [
    path("articles/", views.ArticleListView.as_view(), name="article_list"),
    path("articles/create/", views.ArticleCreateView.as_view(), name="article_create"),
    path("articles/<int:article_id>/", views.ArticleDetailView.as_view(), name="article_detail"),
    path("articles/<int:article_id>/edit/", views.ArticleUpdateView.as_view(), name="article_edit"),
    path("articles/<int:article_id>/delete/", views.article_delete, name="article_delete"),
]

改寫 DeleteView

最後來改寫刪除功能。

參考文件

想了解更多 DeleteView 的細節,可以參考 Django 官方文件

改寫 article_delete

DeleteView 用來處理刪除資料的邏輯:

blog/views.py
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")
        return redirect(self.get_success_url())


class ArticleUpdateView(UpdateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_edit.html"
    pk_url_kwarg = "article_id"

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, f"文章「{self.object.title}」已成功更新。")
        return redirect(self.get_success_url())


class ArticleDeleteView(DeleteView):  # (1)!
    model = Article
    template_name = "blog/article_delete.html"  # (2)!
    pk_url_kwarg = "article_id"  # (3)!
    success_url = reverse_lazy("blog:article_list")  # (4)!
  1. 繼承 DeleteView 類別
  2. 指定模板名稱(預設是 blog/article_confirm_delete.html,但我們使用 article_delete.html
  3. 設定 pk_url_kwargarticle_id
  4. 使用 reverse_lazy() 設定刪除成功後的導向

使用 reverse_lazy

你可能會好奇,為什麼這裡使用 reverse_lazy() 而不是 reverse()

class ArticleDeleteView(DeleteView):
    model = Article
    template_name = "blog/article_delete.html"
    pk_url_kwarg = "article_id"
    success_url = reverse_lazy("blog:article_list")  # 為什麼用 reverse_lazy?

原因是 success_url 是在類別定義時就會被執行的屬性,而此時 Django 的 URL 設定可能還沒載入完成。reverse_lazy() 會延遲到真正需要時才執行,確保 URL 設定已經載入。

簡單來說:

  • reverse():立即執行,適合在方法中使用
  • reverse_lazy():延遲執行,適合在類別屬性中使用

加入刪除成功訊息

現在加入刪除成功的訊息:

blog/views.py
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")
        return redirect(self.get_success_url())


class ArticleUpdateView(UpdateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_edit.html"
    pk_url_kwarg = "article_id"

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, f"文章「{self.object.title}」已成功更新。")
        return redirect(self.get_success_url())


class ArticleDeleteView(DeleteView):
    model = Article
    template_name = "blog/article_delete.html"
    pk_url_kwarg = "article_id"
    success_url = reverse_lazy("blog:article_list")

    def form_valid(self, form):  # (1)!
        messages.success(self.request, f"文章「{self.object.title}」已成功刪除。")  # (2)!
        self.object.delete()  # (3)!
        return redirect(self.get_success_url())  # (4)!
  1. 覆寫 form_valid() 來加入刪除成功訊息
  2. 顯示成功訊息(self.object 已經由 DeleteView 自動設定)
  3. 執行刪除操作
  4. 手動重定向到成功頁面

現在更新 urls.py

blog/urls.py
from django.urls import path

from blog import views

app_name = "blog"

urlpatterns = [
    path("articles/", views.ArticleListView.as_view(), name="article_list"),
    path("articles/create/", views.ArticleCreateView.as_view(), name="article_create"),
    path("articles/<int:article_id>/", views.ArticleDetailView.as_view(), name="article_detail"),
    path("articles/<int:article_id>/edit/", views.ArticleUpdateView.as_view(), name="article_edit"),
    path("articles/<int:article_id>/delete/", views.ArticleDeleteView.as_view(), name="article_delete"),
]

使用 Mixin 加入權限控制

目前我們已經把所有的 FBV 改寫成 CBV 了,但是還缺少權限控制。讓我們使用 Mixin 來加入權限檢查。

什麼是 Mixin

Mixin 是一種設計模式,透過多重繼承來為類別加入額外的功能。在 CBV 中,Mixin 是一個包含特定功能的類別,可以和其他類別組合使用。

例如,Django 提供的 LoginRequiredMixin 可以要求使用者必須登入才能存取 view,PermissionRequiredMixin 則可以檢查使用者是否有特定權限。

使用 Mixin 的關鍵是繼承順序

class MyView(Mixin1, Mixin2, BaseView):  # (1)!
    pass
  1. Mixin 要放在最左邊,基礎類別(如 CreateView)放在最右邊

Python 的多重繼承使用 MRO(Method Resolution Order)來決定方法的查找順序,按照從左到右、從子類別到父類別的順序。因此 Mixin 要放在最左邊,才能在基礎類別之前執行。

PermissionRequiredMixin

讓我們為 ArticleCreateViewArticleUpdateViewArticleDeleteView 加入權限檢查。詳細資訊可參考 Django 官方文件

blog/views.py
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(PermissionRequiredMixin, CreateView):  # (1)!
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"
    permission_required = "blog.add_article"  # (2)!
    raise_exception = True  # (3)!

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.object.save()
        form.save_m2m()
        messages.success(self.request, f"文章「{self.object.title}」已成功建立。")
        return redirect(self.get_success_url())


class ArticleUpdateView(PermissionRequiredMixin, UpdateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_edit.html"
    pk_url_kwarg = "article_id"
    permission_required = "blog.change_article"
    raise_exception = True

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, f"文章「{self.object.title}」已成功更新。")
        return redirect(self.get_success_url())


class ArticleDeleteView(PermissionRequiredMixin, DeleteView):
    model = Article
    template_name = "blog/article_delete.html"
    pk_url_kwarg = "article_id"
    success_url = reverse_lazy("blog:article_list")
    permission_required = "blog.delete_article"
    raise_exception = True

    def form_valid(self, form):
        messages.success(self.request, f"文章「{self.object.title}」已成功刪除。")
        self.object.delete()
        return redirect(self.get_success_url())
  1. 在最左邊加入 PermissionRequiredMixin
  2. 設定需要的權限
  3. 當權限不足時回傳 403 錯誤(而不是導向登入頁面)

現在我們的 view 已經有權限保護了,不需要再使用 @permission_required 裝飾器。

SuccessMessageMixin

你可能注意到我們在每個 view 中都需要覆寫 form_valid() 來加入成功訊息,這樣有點重複。Django 提供了 SuccessMessageMixin 來簡化這個流程。

SuccessMessageMixin 讓我們只需要設定 success_message 屬性,就能自動顯示成功訊息,不需要覆寫 form_valid()。讓我們用它來改寫 ArticleCreateViewArticleUpdateView

blog/views.py
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(PermissionRequiredMixin, SuccessMessageMixin, CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"
    permission_required = "blog.add_article"
    raise_exception = True
    success_message = "文章「%(title)s」已成功建立。"

    def form_valid(self, form):
        form.instance.created_by = self.request.user
        return super().form_valid(form)


class ArticleUpdateView(PermissionRequiredMixin, SuccessMessageMixin, UpdateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_edit.html"
    pk_url_kwarg = "article_id"
    permission_required = "blog.change_article"
    raise_exception = True
    success_message = "文章「%(title)s」已成功更新。"


class ArticleDeleteView(PermissionRequiredMixin, SuccessMessageMixin, DeleteView):
    model = Article
    template_name = "blog/article_delete.html"
    pk_url_kwarg = "article_id"
    success_url = reverse_lazy("blog:article_list")
    permission_required = "blog.delete_article"
    raise_exception = True

    def get_success_message(self, cleaned_data):
        return f"文章「{self.object.title}」已成功刪除。"

使用 SuccessMessageMixin 的好處:

  • 程式碼更簡潔:不需要在每個 view 中重複寫 messages.success()
  • 宣告式風格:直接設定 success_message 屬性,意圖更清楚
  • 支援格式化:可以使用 %(field_name)s 來動態插入物件的欄位值

DeleteView 使用 SuccessMessageMixin 的注意事項

DeleteView 也可以使用 SuccessMessageMixin,但需要注意的是:

  • 不能直接使用 success_message = "文章「%(title)s」已成功刪除。",因為在 title 資訊不會存在 form 中。
  • 需要覆寫 get_success_message() 方法,並在方法中使用 self.object 來取得物件資訊
  • self.objectget_success_message() 被呼叫時還存在,所以可以安全地取得物件的欄位值

ArticleCreateView 的 form_valid

可以注意一下 ArticleCreateViewform_valid() 方法,這邊我們換了一個寫法。

之前的寫法

def form_valid(self, form):
    self.object = form.save(commit=False)
    self.object.created_by = self.request.user
    self.object.save()
    form.save_m2m()
    return redirect(self.get_success_url())

前面我們自己處理物件的儲存(不呼叫 super().form_valid(form) )這與我們在寫 FBV 時的做法相同,但現在我們使用 SuccessMessageMixin 時用這個方法會造成 message 無法正常被發出。

因為發送 message 的動作是寫在 SuccessMessageMixin 的 form_valid 方法中,所以我們這邊得換一個做法讓父層的 form_valid 會被呼叫到。

會進入 form_valid 方法表單已經過驗證成功,我們就直接更新表單產生的物件(instance)並透過呼叫父層的 form_valid 讓父層來負責儲存物件與觸發後續的處理。

Mixin 的順序

再次強調,Mixin 的順序很重要:

# 正確 ✓
class ArticleCreateView(PermissionRequiredMixin, SuccessMessageMixin, CreateView):
    pass

# 錯誤 ✗ - 基礎類別不應該在最左邊
class ArticleCreateView(CreateView, PermissionRequiredMixin, SuccessMessageMixin):
    pass

# 也可以接受 - Mixin 順序調換(但不建議)
class ArticleCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):
    pass

順序原則:

  1. 基礎類別(如 CreateView)要放在最右邊:這是最重要的規則
  2. 功能性 Mixin 的順序:通常按照「檢查 → 處理 → 呈現」的邏輯排列
    • 權限檢查 Mixin(如 PermissionRequiredMixin)放在最左邊
    • 資料處理 Mixin(如 SuccessMessageMixin)放在中間
    • 基礎類別放在最右邊

如果順序錯誤,可能導致:

  • 權限檢查不會被執行(安全性問題)
  • 成功訊息不會顯示(功能問題)
  • 方法覆寫的順序不如預期(邏輯問題)

使用內建的 CBV

除了前面介紹的 CRUD 相關 Generic Views,Django 還提供了一些簡單實用的基礎 View,其中最常用的就是 RedirectView

RedirectView

RedirectView 專門用來處理網址重定向。

在 FBV 中,如果我們要將首頁重定向到文章列表,通常會這樣寫:

def redirect_to_index(request):
    return redirect("blog:article_list")

但使用 RedirectView,我們可以直接在 urls.py 中設定,完全不需要寫 View 程式碼。

讓我們實際操作看看!目前連線到首頁 / 時會出現 404,讓我們把它重定向到文章列表。請將 core/urls.py 的內容修改如下:

core/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy
from django.views.generic import RedirectView

from core import views

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
    path(
        "password-reset/",
        auth_views.PasswordResetView.as_view(
            template_name="registration/password_reset.html",
            success_url=reverse_lazy("auth:password_reset_done"),
        ),
        name="password_reset",
    ),
    path(
        "password-reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            template_name="registration/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "password-reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            template_name="registration/password_reset_confirm.html",
            success_url=reverse_lazy("auth:password_reset_complete"),
        ),
        name="password_reset_confirm",
    ),
    path(
        "password-reset/complete/",
        auth_views.PasswordResetCompleteView.as_view(
            template_name="registration/password_reset_complete.html"
        ),
        name="password_reset_complete",
    ),
]

urlpatterns = [
    path("", RedirectView.as_view(pattern_name="blog:article_list"), name="root"),
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [
        *urlpatterns,
        *debug_toolbar_urls(),
        *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
        *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
    ]

這樣一來,當使用者訪問首頁時,就會自動被導向到文章列表頁面了!

RedirectView 的常用屬性:

  • pattern_name: 目標 URL pattern 名稱(類似 reverse),最推薦使用
  • url: 目標網址(字串),適合外部連結
  • permanent: 是否為永久重定向(HTTP 301),預設為 False(HTTP 302)

這在處理網址重構或是設定首頁導向時非常實用。

CBV 的常用客製化方法

讓我們整理一下在 CBV 中常用的客製化方法。

常用的可覆寫方法

queryset 屬性與 get_queryset() 方法

使用 queryset 屬性(適合簡單的查詢優化):

class ArticleListView(ListView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")

使用 get_queryset() 方法(適合需要根據 request 動態決定查詢條件):

class ArticleListView(ListView):
    model = Article

    def get_queryset(self):
        # 根據 request.user 動態決定查詢條件
        if self.request.user.is_staff:
            return Article.objects.all()
        return Article.objects.filter(is_published=True)

如果查詢條件是固定的,優先使用 queryset 屬性,程式碼更簡潔。

get_context_data()

用來加入額外的 context 變數到模板中:

class ArticleListView(ListView):
    model = Article

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)  # 取得預設的 context
        context["total_count"] = self.get_queryset().count()  # 加入總數
        context["filter"] = ArticleFilter(self.request.GET)  # 加入篩選器
        return context

form_valid()

在表單驗證通過後、儲存前執行額外的處理:

class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.created_by = self.request.user  # 設定建立者
        self.object.save()
        form.save_m2m()  # 處理 many-to-many 關係
        messages.success(self.request, "文章建立成功")
        return redirect(self.get_success_url())

get_success_url() 與 get_absolute_url()

有兩種方式設定表單提交成功後的導向位置:

方式一:覆寫 get_success_url()

class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm

    def get_success_url(self):
        # 動態決定導向位置
        return reverse("blog:article_detail", kwargs={"pk": self.object.pk})

方式二:使用 Model 的 get_absolute_url()(推薦)

# 在 Model 中定義
class Article(models.Model):
    # ... 其他欄位 ...

    def get_absolute_url(self):
        return reverse("blog:article_detail", kwargs={"article_id": self.pk})

# View 中不需要定義 get_success_url()
class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    # Django 會自動使用 self.object.get_absolute_url()

推薦使用 get_absolute_url(),因為它可以在整個專案中重用,也是 Django 的最佳實踐。

get_context_data() 的使用時機

當你需要在模板中使用額外的資料時,就可以使用 get_context_data()。常見的使用情境:

  1. 加入相關資料:在文章詳細頁面中加入相關文章列表
  2. 加入統計資料:顯示文章總數、評論數等
  3. 加入篩選器:如我們在 ArticleListView 中整合 django-filter

dispatch() 方法

dispatch() 是 view 中最早被呼叫的方法,它會根據 HTTP 方法(GET、POST 等)來呼叫對應的處理方法(如 get()post())。

你可以覆寫 dispatch() 來在請求處理前做一些檢查或處理:

class ArticleDetailView(DetailView):
    model = Article

    def dispatch(self, request, *args, **kwargs):
        # 在處理請求前做一些檢查
        article = self.get_object()
        if not article.is_published and not request.user.is_staff:
            # 非管理員無法查看未發布的文章
            raise PermissionDenied

        return super().dispatch(request, *args, **kwargs)

FBV vs CBV 的選擇

最後,讓我們討論何時該使用 FBV、何時該使用 CBV。

FBV 的優勢

  • 直觀易懂:對於初學者或不熟悉 OOP 的開發者更友善
  • 彈性高:可以隨意組織程式碼邏輯
  • 除錯容易:錯誤訊息更直接,不需要追蹤繼承鏈

適合使用 FBV 的情境:

  • 簡單的 view,不需要重用
  • 高度客製化的邏輯,不適合套用 Generic View
  • 原型開發或快速驗證想法

CBV 的優勢

  • 可重用性:透過繼承和 Mixin 重用程式碼
  • 結構清晰:職責分明,易於維護
  • 減少重複:標準的 CRUD 操作幾乎不需要寫程式碼

適合使用 CBV 的情境:

  • 標準的 CRUD 操作
  • 需要在多個 view 間共享邏輯
  • 大型專案,需要維護一致的程式碼風格

實務建議

在實務中,你可以混合使用 FBV 和 CBV:

  • 對於標準的 CRUD 操作,使用 Generic Views(CBV)
  • 對於複雜的業務邏輯或特殊需求,使用 FBV
  • 將共用的邏輯抽取成 Mixin 或輔助函式

不要為了使用 CBV 而強行使用 CBV,選擇最適合當前情境的方式才是最重要的。

任務結束

完成!

恭喜你完成了這個任務!現在你已經學會:

  • 了解 Class Based View (CBV) 的概念與優勢
  • 將 DetailView 從 FBV 改寫為 CBV
  • 將 ListView 從 FBV 改寫為 CBV
  • 將 CreateView 從 FBV 改寫為 CBV
  • 將 UpdateView 從 FBV 改寫為 CBV
  • 將 DeleteView 從 FBV 改寫為 CBV
  • 使用 Mixin 加入權限控制
  • 了解 CBV 的常用客製化方法

透過這個任務,你已經把整個 blog app 的 view 從 Function Based View 改寫成了 Class Based View。你學會了如何使用 Django 提供的 Generic Views 來減少重複的程式碼,以及如何透過覆寫方法來客製化 CBV 的行為。

在實務開發中,CBV 特別適合處理標準的 CRUD 操作,可以大幅減少開發時間和維護成本。但記住,選擇 FBV 或 CBV 要視情況而定,不要為了使用而使用,選擇最適合當前需求的方式才是最重要的。