跳轉到

文章列表篩選與搜尋

開始之前

任務目標

在這個章節中,我們會完成:

  • 了解為什麼需要篩選功能
  • 使用 GET 參數手動實作篩選
  • 安裝 django-filter 套件
  • 使用 FilterSet 實作進階篩選
  • 整合篩選表單到 Template

為什麼需要篩選功能?

當文章數量增加時,使用者需要快速找到想要的內容:

情境一:使用者想找特定作者的文章
→ 需要「按作者篩選」功能

情境二:使用者想搜尋標題關鍵字
→ 需要「標題搜尋」功能

情境三:使用者想找特定標籤的文章
→ 需要「按標籤篩選」功能

情境四:使用者想找最近發布的文章
→ 需要「按日期排序」功能

良好的篩選功能

好的篩選系統應該:

  • ✅ 直覺易用
  • ✅ 回應快速
  • ✅ 可以組合多個條件
  • ✅ 清楚顯示目前的篩選狀態

方法一:手動實作 GET 參數篩選

讓我們先用最基本的方式實作篩選功能,了解其運作原理。

修改 View

修改 blog/views.py 中的 article_list view:

blog/views.py
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render

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


def article_list(request):
    # 從 GET 參數取得篩選條件
    search = request.GET.get("search", "")  # (1)!
    author_id = request.GET.get("author", "")  # (2)!

    # 建立基本 QuerySet
    articles = Article.objects.select_related("author").prefetch_related("tags")

    # 根據搜尋關鍵字篩選標題
    if search:
        articles = articles.filter(title__icontains=search)  # (3)!

    # 根據作者篩選
    if author_id:
        articles = articles.filter(author_id=author_id)  # (4)!

    return render(
        request, "blog/article_list.html", {"articles": articles, "search": search}
    )


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


def article_create(request):
    form = ArticleForm(request.POST or None)
    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_create.html", {"form": form})


def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST 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})


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. 取得搜尋關鍵字,預設為空字串
  2. 取得作者 ID,預設為空字串
  3. 使用 icontains 做不區分大小寫的模糊搜尋
  4. 根據作者 ID 精確篩選

程式碼重點

GET 參數取得

  • request.GET.get("參數名稱", "預設值")
  • 如果 URL 是 ?search=Django,則 search 會是 "Django"
  • 如果沒有該參數,則使用預設值

條件篩選

  • 只有當參數有值時才執行篩選
  • 使用 if search: 檢查參數是否為空

QuerySet 鏈式調用

  • 可以連續呼叫多個 filter()
  • Django 會自動組合成一個 SQL 查詢

在 Template 加入搜尋表單

修改 blog/templates/blog/article_list.html,在文章列表上方加入搜尋表單:

blog/templates/blog/article_list.html
{% extends "blog/base.html" %}

{% block title %}
  文章列表 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h2>文章列表</h2>
    <a href="{% url 'blog:article_create' %}" class="btn btn-primary">
      建立文章
    </a>
  </div>

  <div class="card mb-4">
    <div class="card-body">
      <form method="get" class="row g-3">  {# (1)! #}
        <div class="col-md-10">
          <input type="text"
                 name="search"
                 class="form-control"
                 placeholder="搜尋標題..."
                 value="{{ search }}">  {# (2)! #}
        </div>
        <div class="col-md-2">
          <button type="submit" class="btn btn-primary w-100">
            搜尋
          </button>
        </div>
      </form>
    </div>
  </div>

  <div class="row">
    {% for article in articles %}
      {% include "blog/components/article_card.html" %}  {# (3)! #}
    {% empty %}
      <div class="col-12">
        <div class="alert alert-info">
          目前沒有文章
        </div>
      </div>
    {% endfor %}
  </div>
{% endblock blog_content %}
  1. 使用 GET 方法,參數會出現在 URL 中
  2. 顯示目前的搜尋關鍵字(保持表單狀態)
  3. 使用 article_card.html 組件顯示文章卡片

GET vs POST

使用 GET 的原因

  1. 可以分享連結/articles/?search=Django 可以直接分享給別人
  2. 可以加入書籤:使用者可以收藏搜尋結果
  3. 符合語意:GET 用於取得資料,不修改資料
  4. 瀏覽器歷史:可以使用上一頁、下一頁

不使用 POST 的原因

  • POST 用於修改資料(建立、更新、刪除)
  • POST 的結果無法被分享或加入書籤
  • 重新整理頁面會出現「重新送出表單」警告

測試手動篩選

啟動開發伺服器:

uv run manage.py runserver

測試搜尋功能

  1. 訪問 http://127.0.0.1:8000/blog/articles/
  2. 在搜尋框輸入關鍵字,例如「Django」
  3. 點擊「搜尋」
  4. URL 變成 /articles/?search=Django
  5. 只顯示標題包含「Django」的文章

測試手動指定參數

直接訪問:http://127.0.0.1:8000/blog/articles/?author=1 → 只顯示作者 ID 為 1 的文章

手動篩選的優缺點

優點

  • ✅ 簡單直接
  • ✅ 完全掌控邏輯
  • ✅ 不需要額外套件

缺點

  • ❌ 程式碼重複(每個篩選條件都要寫一次)
  • ❌ 難以維護(新增篩選條件要改 View 和 Template)
  • ❌ 沒有驗證(使用者可以輸入任意參數)
  • ❌ 功能有限(難以實作進階篩選)

讓我們使用 django-filter 來解決這些問題!

方法二:使用 django-filter 套件

django-filter 是一個強大的套件,可以輕鬆實作複雜的篩選功能。

安裝 django-filter

使用 uv 安裝:

uv add django-filter

設定 Django

core/settings.py 中加入 django-filterINSTALLED_APPS

core/settings.py
INSTALLED_APPS = [
    # Django 內建 apps
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 第三方 apps
    "django_bootstrap5",
    "django_extensions",
    "django_filters", # (1)!
    # 本地 apps
    "practices",
    "blog",
]
  1. 注意是 django_filters(底線,有 s)

套件名稱注意

  • 安裝時django-filter(連字號,無 s)
  • INSTALLED_APPSdjango_filters(底線,有 s)

這是套件的命名,不要搞混了!

建立 FilterSet

建立 blog/filters.py

blog/filters.py
import django_filters

from blog.models import Article


class ArticleFilter(django_filters.FilterSet):  # (1)!
    class Meta:
        model = Article
        fields = {  # (2)!
            "title": ["icontains"],  # (3)!
            "author": ["exact"],  # (4)!
        }
  1. 繼承 FilterSet 類別
  2. 使用字典格式定義欄位及其查詢方式
  3. title 使用 icontains(不區分大小寫的模糊搜尋)
  4. author 使用 exact(精確符合)

FilterSet 的優勢

自動產生

  • ✅ 自動產生表單欄位
  • ✅ 自動產生 QuerySet 篩選邏輯
  • ✅ 自動處理參數驗證

彈性高

  • ✅ 可以客製化每個欄位的行為
  • ✅ 支援多種篩選類型(精確、模糊、範圍等)
  • ✅ 可以篩選關聯欄位

修改 View

修改 blog/views.py

blog/views.py
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render

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


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


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


def article_create(request):
    form = ArticleForm(request.POST or None)
    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_create.html", {"form": form})


def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST 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})


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. 使用 filter_ 變數名稱(因為 filter 是 Python 內建函數)

FilterSet 的運作

# 1. 建立 Filter
filter_ = ArticleFilter(request.GET, queryset=Article.objects.all())

# 2. Filter 自動根據 GET 參數篩選
# 例如:?title__icontains=Django&author=1
# → filter_.qs 會是篩選後的 QuerySet

# 3. filter_.qs 就是篩選後的結果
articles = filter_.qs

Filter 物件包含

  • filter_.form:篩選表單
  • filter_.qs:篩選後的 QuerySet
  • filter_.data:GET 參數資料

為什麼使用 filter_ 而不是 filter?

原因filter 是 Python 的內建函數

# filter 是 Python 內建函數,用於過濾序列
numbers = [1, 2, 3, 4, 5]
even_numbers = filter(lambda x: x % 2 == 0, numbers)

# 如果使用 filter 作為變數名稱,會覆蓋內建函數
filter = ArticleFilter(...)  # ❌ 覆蓋了內建的 filter 函數

解決方法

  • 使用 filter_(加底線)
  • 或使用其他名稱如 article_filter

慣例上使用 filter_ 最簡潔明瞭。

更新 Template

修改 blog/templates/blog/article_list.html

blog/templates/blog/article_list.html
{% extends "blog/base.html" %}

{% load django_bootstrap5 %}

{% block title %}
  文章列表 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h2>
      文章列表
    </h2>
    <a href="{% url 'blog:article_create' %}" class="btn btn-primary">
      建立文章
    </a>
  </div>

  <div class="card mb-4">
    <div class="card-body">
      <h5 class="card-title">
        篩選條件
      </h5>
      <form method="get">
        {% bootstrap_form filter.form %}

        <div class="d-flex gap-2">
          {% bootstrap_button button_type="submit" content="搜尋" %}
          <a href="{% url 'blog:article_list' %}" class="btn btn-secondary">清除</a>
        </div>
      </form>
    </div>
  </div>

  <div class="row">
    {% for article in filter.qs %}
      {% include "blog/components/article_card.html" %}
    {% empty %}
      <div class="col-12">
        <div class="alert alert-info">
          沒有符合條件的文章
        </div>
      </div>
    {% endfor %}
  </div>
{% endblock blog_content %}

bootstrap_form 的優勢

{% bootstrap_form filter.form %} 會自動:

  • ✅ 渲染所有篩選欄位
  • ✅ 套用 Bootstrap 樣式
  • ✅ 顯示欄位標籤
  • ✅ 保持表單狀態(顯示目前的篩選條件)

對比手動寫法

<!-- ❌ 手動:需要逐一指定欄位 -->
{% bootstrap_field filter.form.title %}
{% bootstrap_field filter.form.author %}
<!-- 每新增一個篩選欄位都要加一行 -->

<!-- ✅ 自動:一行搞定所有欄位 -->
{% bootstrap_form filter.form %}

這樣新增篩選欄位時,Template 不需要修改!

測試 django-filter

測試標題搜尋

  1. 訪問文章列表頁
  2. 在「標題」欄位輸入關鍵字
  3. 點擊「搜尋」
  4. 只顯示標題包含關鍵字的文章
  5. 表單保持輸入的關鍵字

測試作者篩選

  1. 在「作者」下拉選單選擇特定作者
  2. 點擊「搜尋」
  3. 只顯示該作者的文章
  4. 下拉選單保持選擇的作者

測試組合篩選

  1. 同時輸入標題關鍵字和選擇作者
  2. 點擊「搜尋」
  3. 顯示「該作者」且「標題包含關鍵字」的文章

測試清除篩選

  1. 點擊「清除」按鈕
  2. 回到文章列表頁
  3. 顯示所有文章

進階:新增更多篩選欄位

加入標籤篩選

修改 blog/filters.py,加入標籤篩選:

blog/filters.py
import django_filters

from blog.models import Article


class ArticleFilter(django_filters.FilterSet):
    class Meta:
        model = Article
        fields = {
            "title": ["icontains"],
            "author": ["exact"],
            "tags": ["exact"],  # (1)!
        }
  1. 加入 tags 篩選,使用 exact(精確符合)

自動產生的優勢

django-filter 會根據欄位類型自動選擇合適的表單元件:

  • title(CharField)→ 文字輸入框
  • author(ForeignKey)→ 下拉選單
  • tags(ManyToManyField)→ 多選框

完全不需要手動設定!

使用多個 lookup_expr

你可以為同一個欄位設定多個查詢方式:

blog/filters.py
import django_filters

from blog.models import Article


class ArticleFilter(django_filters.FilterSet):
    class Meta:
        model = Article
        fields = {
            "title": ["exact", "icontains"],  # (1)!
            "author": ["exact"],
            "tags": ["exact"],
        }
  1. title 會產生兩個篩選欄位:精確搜尋和模糊搜尋

常用 lookup_expr

lookup_expr 說明 適用欄位
exact 精確符合 所有欄位
iexact 不區分大小寫精確符合 文字欄位
contains 包含 文字欄位
icontains 不區分大小寫包含 文字欄位
gt / gte 大於 / 大於等於 數字、日期
lt / lte 小於 / 小於等於 數字、日期

何時需要自訂篩選欄位?

大多數情況下,使用 Meta.fields 就足夠了。只有在以下情況才需要自訂欄位:

  1. 需要客製化中文標籤
  2. 需要特殊的 widget(如日期選擇器)
  3. 需要自訂查詢邏輯

在這些情況下,可以自訂 Filter 欄位:

class ArticleFilter(django_filters.FilterSet):
    # 自訂欄位:可以設定 label、widget 等
    title = django_filters.CharFilter(
        field_name="title",
        lookup_expr="icontains",
        label="文章標題",  # 自訂標籤
    )

    class Meta:
        model = Article
        fields = {
            "author": ["exact"],  # 其他欄位仍使用自動產生
        }

Meta.fields 的兩種寫法

寫法一:列表(簡單)

class ArticleFilter(django_filters.FilterSet):
    class Meta:
        model = Article
        fields = ["title", "author", "tags"]  # (1)!
  1. 使用預設的 exact 查詢

優點:簡潔
缺點:無法指定查詢方式

寫法二:字典(彈性)

class ArticleFilter(django_filters.FilterSet):
    class Meta:
        model = Article
        fields = {
            "title": ["icontains"],  # (1)!
            "author": ["exact"],
            "tags": ["exact"],
        }
  1. 可以為每個欄位指定查詢方式

優點:可以自訂查詢方式
缺點:稍微冗長

建議

大多數情況下使用字典寫法,這樣可以:

  • 明確知道每個欄位的查詢方式
  • 方便日後調整

常見問題

為什麼篩選沒有生效?

檢查以下幾點:

  1. 是否在 INSTALLED_APPS 加入 django_filters

    INSTALLED_APPS = [
        ...
        "django_filters",  # 注意是底線和有 s
    ]
    
  2. FilterSet 的 Meta 中是否指定欄位

    class Meta:
        model = Article
        fields = ["title", "author"]  # 必須指定
    
  3. View 中是否正確使用 filter_.qs

    filter_ = ArticleFilter(request.GET, queryset=Article.objects.all())
    articles = filter_.qs  # 使用 filter_.qs,不是 queryset
    

如何客製化表單樣式?

使用 {% bootstrap_form %} 就已經有 Bootstrap 樣式了。

如果需要更進一步客製化,可以在 FilterSet 中設定 widget

title = django_filters.CharFilter(
    widget=forms.TextInput(attrs={
        "class": "form-control",
        "placeholder": "輸入標題...",
    })
)

或者在 Template 中手動控制:

<div class="mb-3">
  <label>{{ filter.form.title.label }}</label>
  {{ filter.form.title }}
</div>

如何實作「清除篩選」按鈕?

最簡單的方法是連結回不帶參數的列表頁:

<a href="{% url 'blog:article_list' %}" class="btn btn-secondary">
  清除篩選
</a>

任務結束

完成!

恭喜你完成了這個章節!現在你已經:

  • 了解為什麼需要篩選功能
  • 使用 GET 參數手動實作篩選
  • 安裝 django-filter 套件
  • 使用 FilterSet 實作進階篩選
  • 整合篩選表單到 Template

你學會了:

  1. GET 參數的使用:適合用於篩選和搜尋功能
  2. django-filter 的優勢:自動產生篩選邏輯和表單
  3. FilterSet 的設定:各種篩選欄位類型和選項
  4. 整合 django-bootstrap5:快速產生美觀的表單