跳轉到

使用 ModelForm 編輯文章

開始之前

任務目標

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

  • 了解 ModelForm 如何處理編輯功能
  • 使用 instance 參數預填表單資料
  • 實作文章編輯功能
  • 共用表單 template 減少重複
  • 在文章詳情頁加入編輯按鈕

ModelForm 與資料編輯

在上一章節中,我們使用 ModelForm 實作了文章建立功能。ModelForm 不只可以建立新資料,還能輕鬆地編輯現有資料。

建立 vs 編輯

功能 建立 編輯
Form 初始化 ArticleForm() ArticleForm(instance=article)
表單狀態 空白表單 預填現有資料
save() 行為 建立新物件 更新現有物件
重導向 文章詳情頁 文章詳情頁

instance 參數的魔力

當我們傳入 instance 參數給 ModelForm:

  1. 表單會自動填入資料:所有欄位顯示現有的值
  2. save() 會更新而非建立:呼叫 form.save() 會更新該物件
  3. 驗證包含當前物件:unique 驗證會排除當前物件

這讓建立和編輯功能可以使用同一個 Form 類別!

實作文章編輯功能

讓我們來實作文章編輯功能。

建立 View

blog/views.py 中新增 article_edit view:

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

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


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


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()
        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)  # (1)!
    form = ArticleForm(request.POST or None, instance=article)  # (2)!
    if form.is_valid():
        article = form.save()  # (3)!
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})
  1. 取得要編輯的文章,如果不存在則回傳 404
  2. 傳入 instance 參數,讓表單預填資料
  3. save() 會更新現有物件,而非建立新物件

程式碼解析

這個 view 和 article_create 非常相似,差別在於:

  1. 取得文章物件:使用 get_object_or_404 取得要編輯的文章
  2. 傳入 instanceArticleForm(request.POST or None, instance=article)
    • GET 請求:顯示預填資料的表單
    • POST 請求:驗證並更新資料
  3. 傳遞 article 到 template:用於顯示標題等資訊

建立 Template

建立 blog/templates/blog/article_edit.html:

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

{% load django_bootstrap5 %}

{% block title %}
  編輯文章:{{ article.title }} - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <nav aria-label="breadcrumb" class="d-none d-md-block mb-3">
    <ol class="breadcrumb">
      <li class="breadcrumb-item">
        <a href="{% url 'blog:article_list' %}">文章列表</a>
      </li>
      <li class="breadcrumb-item">
        <a href="{% url 'blog:article_detail' article_id=article.id %}">{{ article.title }}</a>
      </li>
      <li class="breadcrumb-item active">
        編輯
      </li>
    </ol>
  </nav>

  <div class="card">
    <div class="card-body">
      <h2 class="card-title mb-4">
        編輯文章
      </h2>

      {% bootstrap_form_errors form type='non_fields' %}

      <form method="post">
        {% csrf_token %}

        {% bootstrap_field form.title addon_before='<i class="bi bi-pencil"></i>' placeholder="請輸入標題..." %}

        {% bootstrap_field form.content addon_before='<i class="bi bi-file-text"></i>' rows=10 %}

        {% bootstrap_field form.author addon_before='<i class="bi bi-person"></i>' %}

        <div class="d-flex gap-2 mt-3">
          {% bootstrap_button button_type="submit" content="儲存變更" %}

          {% url 'blog:article_detail' article_id=article.id as cancel_url %}
          {% bootstrap_button button_type="link" href=cancel_url content="取消" button_class="btn-secondary" %}
        </div>
      </form>
    </div>
  </div>
{% endblock blog_content %}

Template 重點

  1. 麵包屑導覽:顯示層級關係(文章列表 → 文章詳情 → 編輯)
  2. 動態標題:顯示正在編輯的文章標題
  3. 取消按鈕:連結回文章詳情頁,而非列表頁
  4. 表單結構:和建立頁面幾乎相同

設定 URL

blog/urls.py 加入編輯的 URL pattern:

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.article_detail, name="article_detail"),
    path("articles/<int:article_id>/edit/", views.article_edit, name="article_edit"),
]

測試功能

啟動開發伺服器:

uv run manage.py runserver

手動訪問編輯頁面(假設文章 ID 為 1):

http://127.0.0.1:8000/blog/articles/1/edit/

你會看到:

  1. 表單預填資料:標題、內容、作者都顯示原本的值
  2. 麵包屑導覽:顯示導覽路徑
  3. 編輯並儲存:修改內容後點擊「儲存變更」

ModelForm 的強大之處

注意我們完全沒有手動處理資料填充:

  • 不需要value="{{ article.title }}"
  • 不需要selected 判斷
  • 不需要:手動更新每個欄位

instance=article 自動幫我們處理了一切!

在文章詳情頁加入編輯按鈕

讓我們在文章詳情頁加入「編輯」按鈕,讓使用者可以快速進入編輯模式。

修改 blog/templates/blog/article_detail.html:

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

{% block title %}
  {{ article.title }} - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <nav aria-label="breadcrumb" class="d-none d-md-block">
    <ol class="breadcrumb">
      <li class="breadcrumb-item">
        <a href="{% url 'blog:article_list' %}">文章列表</a>
      </li>
      <li class="breadcrumb-item active">
        {{ article.title }}
      </li>
    </ol>
  </nav>

  <article class="card">
    <div class="card-body">
      <h1 class="card-title fs-3 fs-md-2 fs-lg-1">
        {{ article.title }}
      </h1>

      <div class="d-flex flex-column flex-md-row align-items-md-center mb-3 text-muted">
        <span class="me-md-3 mb-2 mb-md-0">
          <i class="bi bi-person"></i>
          作者:{{ article.author.name }}
        </span>
        <span>
          <i class="bi bi-calendar"></i>
          發布時間:{{ article.created_at|date:"Y-m-d H:i" }}
        </span>
      </div>

      {% if article.tags.exists %}
        <div class="mb-3">
          {% for tag in article.tags.all %}
            <span class="badge bg-secondary me-1 mb-1">{{ tag.name }}</span>
          {% endfor %}
        </div>
      {% endif %}

      <hr />

      <div class="article-content fs-6 fs-md-5">
        {{ article.content|linebreaks }}
      </div>
    </div>

    <div class="card-footer bg-light">
      <div class="d-flex justify-content-between align-items-center">
        <div class="d-flex gap-2">
          <a href="{% url 'blog:article_list' %}"
             class="btn btn-outline-secondary btn-sm">
            <i class="bi bi-arrow-left"></i>
            <span class="d-none d-sm-inline">返回列表</span>
          </a>
          <a href="{% url 'blog:article_edit' article_id=article.id %}"
             class="btn btn-outline-primary btn-sm">
            <i class="bi bi-pencil"></i>
            <span class="d-none d-sm-inline">編輯</span>
          </a>
        </div>
        <small class="text-muted d-none d-md-inline">
          最後更新:{{ article.updated_at|date:"Y-m-d" }}
        </small>
      </div>
    </div>
  </article>
{% endblock blog_content %}

按鈕設計考量

  1. 圖示 + 文字

    • 手機版:只顯示圖示(節省空間)
    • 桌面版:顯示圖示 + 文字(更清楚)
  2. 按鈕群組

    • 使用 d-flex gap-2 建立按鈕群組
    • 相關操作放在一起
  3. 視覺層級

    • 返回列表:次要操作(btn-outline-secondary
    • 編輯:主要操作(btn-outline-primary

共用表單 Template(進階)

你可能注意到 article_create.htmlarticle_edit.html 有很多重複的程式碼。我們可以將表單部分抽取成一個共用的 template。

建立表單 Component

建立 blog/templates/blog/components/article_form.html:

blog/templates/blog/components/article_form.html
{% load django_bootstrap5 %}

{% bootstrap_form_errors form type='non_fields' %}

<form method="post">
  {% csrf_token %}

  {% bootstrap_field form.title addon_before='<i class="bi bi-pencil"></i>' placeholder="請輸入標題..." %}

  {% bootstrap_field form.content addon_before='<i class="bi bi-file-text"></i>' rows=10 %}

  {% bootstrap_field form.author addon_before='<i class="bi bi-person"></i>' %}

  <div class="d-flex gap-2 mt-3">
    {% bootstrap_button button_type="submit" content=submit_text %}

    {% if show_reset %}
      {% bootstrap_button button_type="reset" content="清除內容" button_class="btn-info" %}
    {% endif %}

    {% bootstrap_button button_type="link" href=cancel_url content="取消" button_class="btn-secondary" %}
  </div>
</form>

Component 參數

這個 component 接受以下參數:

  • form:必填,表單物件
  • submit_text:必填,送出按鈕的文字
  • cancel_url:必填,取消按鈕的連結
  • show_reset:選填,是否顯示「清除內容」按鈕

更新建立頁面

修改 blog/templates/blog/article_create.html:

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

{% block title %}
  建立文章 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="card">
    <div class="card-body">
      <h2 class="card-title mb-4">
        建立文章
      </h2>

      {% url 'blog:article_list' as cancel_url %}
      {% include "blog/components/article_form.html" with submit_text="建立文章" show_reset=True %}
    </div>
  </div>
{% endblock blog_content %}

更新編輯頁面

修改 blog/templates/blog/article_edit.html:

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

{% block title %}
  編輯文章:{{ article.title }} - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <nav aria-label="breadcrumb" class="d-none d-md-block mb-3">
    <ol class="breadcrumb">
      <li class="breadcrumb-item">
        <a href="{% url 'blog:article_list' %}">文章列表</a>
      </li>
      <li class="breadcrumb-item">
        <a href="{% url 'blog:article_detail' article_id=article.id %}">{{ article.title }}</a>
      </li>
      <li class="breadcrumb-item active">
        編輯
      </li>
    </ol>
  </nav>

  <div class="card">
    <div class="card-body">
      <h2 class="card-title mb-4">
        編輯文章
      </h2>

      {% url 'blog:article_detail' article_id=article.id as cancel_url %}
      {% include "blog/components/article_form.html" with submit_text="儲存變更" show_reset=False %}
    </div>
  </div>
{% endblock blog_content %}

DRY 原則

Don't Repeat Yourself(不要重複自己)是程式設計的重要原則。

好處:

  • ✅ 減少重複程式碼
  • ✅ 統一樣式和行為
  • ✅ 修改時只需改一個地方
  • ✅ 降低維護成本

何時抽取 Component:

  • 兩個以上地方使用相同的 HTML
  • 該部分有明確的功能邊界
  • 參數化後不會變得更複雜

ModelForm 處理更新的原理

讓我們深入了解 ModelForm 如何處理資料更新。

Form 初始化

# 建立:沒有 instance
form = ArticleForm()
# form.instance 是一個新的、未儲存的 Article 物件

# 編輯:有 instance
article = Article.objects.get(id=1)
form = ArticleForm(instance=article)
# form.instance 就是傳入的 article 物件

save() 方法的行為

def save(self, commit=True):
    if self.instance.pk:  # (1)!
        # 更新現有物件
        self.instance.title = self.cleaned_data['title']
        self.instance.content = self.cleaned_data['content']
        # ...
        if commit:
            self.instance.save()  # (2)!
    else:
        # 建立新物件
        self.instance = Article.objects.create(...)

    return self.instance
  1. 檢查物件是否有主鍵(pk)來判斷是新建還是更新
  2. 呼叫 Model 的 save() 方法更新資料庫

pk(Primary Key)

  • 有 pk:物件已經在資料庫中,執行 UPDATE
  • 沒有 pk:新物件,執行 INSERT

ModelForm 自動處理這個邏輯!

完整流程

sequenceDiagram
    participant User
    participant View
    participant Form
    participant DB

    User->>View: GET /articles/1/edit/
    View->>DB: 查詢 Article(id=1)
    DB-->>View: article 物件
    View->>Form: ArticleForm(instance=article)
    Form-->>View: 預填資料的表單
    View-->>User: 顯示表單

    User->>View: POST 修改後的資料
    View->>Form: ArticleForm(POST, instance=article)
    Form->>Form: is_valid() 驗證
    Form->>DB: save() 更新資料
    DB-->>Form: 成功
    View-->>User: 重導向到詳情頁

常見問題

為什麼編輯要傳入 instance?

不傳入 instance 的話:

# ❌ 錯誤:沒有 instance
form = ArticleForm(request.POST)
if form.is_valid():
    form.save()  # 會建立新文章,而非更新!

這會建立一個新的文章,而不是更新現有的。

可以調整更新後的物件嗎?

可以!使用 save(commit=False):

article = get_object_or_404(Article, id=article_id)
form = ArticleForm(request.POST, instance=article)
if form.is_valid():
    article = form.save(commit=False)
    article.is_featured = True  # (1)!
    article.save()
  1. 修改表單以外的欄位

instance 和 initial 有什麼差別?

參數 用途 save() 行為
instance 編輯現有物件 更新該物件
initial 設定預設值 建立新物件
# instance:編輯
article = Article.objects.get(id=1)
form = ArticleForm(instance=article)  # 編輯 article

# initial:預設值
form = ArticleForm(initial={"title": "預設標題"})  # 建立新文章,標題預設為「預設標題」

任務結束

完成!

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

  • 了解 ModelForm 如何處理編輯功能
  • 使用 instance 參數預填表單資料
  • 實作文章編輯功能
  • 共用表單 template 減少重複
  • 在文章詳情頁加入編輯按鈕