使用 ModelForm 編輯文章¶
開始之前¶
任務目標
在這個章節中,我們會完成:
- 了解 ModelForm 如何處理編輯功能
- 使用 instance 參數預填表單資料
- 實作文章編輯功能
- 共用表單 template 減少重複
- 在文章詳情頁加入編輯按鈕
ModelForm 與資料編輯¶
在上一章節中,我們使用 ModelForm 實作了文章建立功能。ModelForm 不只可以建立新資料,還能輕鬆地編輯現有資料。
建立 vs 編輯¶
| 功能 | 建立 | 編輯 |
|---|---|---|
| Form 初始化 | ArticleForm() |
ArticleForm(instance=article) |
| 表單狀態 | 空白表單 | 預填現有資料 |
| save() 行為 | 建立新物件 | 更新現有物件 |
| 重導向 | 文章詳情頁 | 文章詳情頁 |
instance 參數的魔力
當我們傳入 instance 參數給 ModelForm:
- 表單會自動填入資料:所有欄位顯示現有的值
- save() 會更新而非建立:呼叫
form.save()會更新該物件 - 驗證包含當前物件:unique 驗證會排除當前物件
這讓建立和編輯功能可以使用同一個 Form 類別!
實作文章編輯功能¶
讓我們來實作文章編輯功能。
建立 View¶
在 blog/views.py 中新增 article_edit view:
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})
- 取得要編輯的文章,如果不存在則回傳 404
- 傳入
instance參數,讓表單預填資料 save()會更新現有物件,而非建立新物件
程式碼解析
這個 view 和 article_create 非常相似,差別在於:
- 取得文章物件:使用
get_object_or_404取得要編輯的文章 - 傳入 instance:
ArticleForm(request.POST or None, instance=article)- GET 請求:顯示預填資料的表單
- POST 請求:驗證並更新資料
- 傳遞 article 到 template:用於顯示標題等資訊
建立 Template¶
建立 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 重點
- 麵包屑導覽:顯示層級關係(文章列表 → 文章詳情 → 編輯)
- 動態標題:顯示正在編輯的文章標題
- 取消按鈕:連結回文章詳情頁,而非列表頁
- 表單結構:和建立頁面幾乎相同
設定 URL¶
在 blog/urls.py 加入編輯的 URL pattern:
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"),
]
測試功能¶
啟動開發伺服器:
手動訪問編輯頁面(假設文章 ID 為 1):
http://127.0.0.1:8000/blog/articles/1/edit/
你會看到:
- 表單預填資料:標題、內容、作者都顯示原本的值
- 麵包屑導覽:顯示導覽路徑
- 編輯並儲存:修改內容後點擊「儲存變更」
ModelForm 的強大之處
注意我們完全沒有手動處理資料填充:
- ❌ 不需要:
value="{{ article.title }}" - ❌ 不需要:
selected判斷 - ❌ 不需要:手動更新每個欄位
instance=article 自動幫我們處理了一切!
在文章詳情頁加入編輯按鈕¶
讓我們在文章詳情頁加入「編輯」按鈕,讓使用者可以快速進入編輯模式。
修改 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 %}
按鈕設計考量
-
圖示 + 文字:
- 手機版:只顯示圖示(節省空間)
- 桌面版:顯示圖示 + 文字(更清楚)
-
按鈕群組:
- 使用
d-flex gap-2建立按鈕群組 - 相關操作放在一起
- 使用
-
視覺層級:
- 返回列表:次要操作(
btn-outline-secondary) - 編輯:主要操作(
btn-outline-primary)
- 返回列表:次要操作(
共用表單 Template(進階)¶
你可能注意到 article_create.html 和 article_edit.html 有很多重複的程式碼。我們可以將表單部分抽取成一個共用的 template。
建立表單 Component¶
建立 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:
{% 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:
{% 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
- 檢查物件是否有主鍵(pk)來判斷是新建還是更新
- 呼叫 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 的話:
這會建立一個新的文章,而不是更新現有的。
可以調整更新後的物件嗎?¶
可以!使用 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()
- 修改表單以外的欄位
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 減少重複
- 在文章詳情頁加入編輯按鈕