Django Forms 進階:ModelForm、Formset 與檔案上傳 | Django 教學
在前一篇教學中,我們學會了 Django Forms 的基礎用法,包含欄位定義、驗證流程與模板渲染。然而在實務開發中,表單往往需要與 Model 緊密結合、一次處理多筆資料,或是支援 檔案上傳 等進階需求。本篇將帶你深入 ModelForm 的自動表單產生機制、Formset 的批次資料管理、FileField 與 ImageField 的檔案處理,並介紹 Widget 自訂、CBV(Class-Based Views)整合,以及 Form Mixin 設計模式,全面提升你的表單開發能力。
ModelForm:從 Model 自動產生表單
ModelForm(模型表單)是 Django 最常用的表單類型之一。它能自動根據 Model 的欄位定義產生對應的表單欄位,省去手動逐一宣告的麻煩,同時內建 save() 方法讓資料儲存變得極為簡潔。
Meta 類別設定
ModelForm 的核心在於內部的 Meta 類別,它告訴 Django 要從哪個 Model 產生表單、包含哪些欄位,以及如何呈現這些欄位:
# forms.py
from django import forms
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article # 指定對應的 Model
fields = ['title', 'slug', 'content', 'cover_image', 'tags', 'is_published']
# 也可以用 exclude 排除不需要的欄位
# exclude = ['author', 'created_at']
labels = {
'title': '標題',
'slug': 'URL Slug',
'content': '內容',
'is_published': '立即發布',
}
widgets = {
'content': forms.Textarea(attrs={
'rows': 15,
'class': 'markdown-editor',
}),
'tags': forms.CheckboxSelectMultiple(),
}
help_texts = {
'slug': '僅可使用英文、數字和連字號,用於 URL 路徑。',
}
Meta 類別的常用屬性說明:
| 屬性 | 說明 |
|---|---|
model | 指定要產生表單的 Model 類別 |
fields | 明確列出要包含的欄位(推薦使用,安全性較高) |
exclude | 排除不需要的欄位(其餘全部包含) |
widgets | 自訂各欄位的 Widget(控件) |
labels | 自訂各欄位的顯示標籤 |
help_texts | 自訂各欄位的輔助說明文字 |
重要提醒:建議優先使用 fields 而非 exclude。使用 exclude 時,如果之後 Model 新增了敏感欄位(如 is_admin),可能會不小心暴露在表單中,造成安全風險。
save() 方法:commit 參數
ModelForm 的 save() 方法會將表單資料儲存到資料庫,並回傳 Model 實例。透過 commit 參數可以控制是否立即寫入資料庫:
# views.py
def create_article(request):
if request.method == 'POST':
form = ArticleForm(request.POST, request.FILES)
if form.is_valid():
# commit=True(預設):直接儲存到資料庫
article = form.save()
# commit=False:取得未儲存的 Model 實例,可先修改再手動儲存
article = form.save(commit=False)
article.author = request.user # 補上 author 欄位
article.save() # 手動儲存到資料庫
form.save_m2m() # commit=False 時,ManyToMany 欄位需要另外呼叫
return redirect('article_detail', pk=article.pk)
else:
form = ArticleForm()
return render(request, 'articles/create.html', {'form': form})
當 commit=False 時,save() 回傳一個尚未寫入資料庫的 Model 實例,你可以在呼叫 instance.save() 之前修改任何欄位。特別注意,如果表單包含 ManyToManyField(多對多欄位),必須在 instance.save() 之後再呼叫 form.save_m2m() 來儲存多對多關聯。
覆寫 save() 自訂儲存邏輯
當儲存流程需要更複雜的自訂邏輯時,可以直接在 ModelForm 中覆寫 save() 方法:
# forms.py
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'slug', 'content', 'cover_image', 'is_published']
def save(self, commit=True, author=None):
"""覆寫 save() 方法,加入自訂邏輯"""
article = super().save(commit=False)
if author:
article.author = author
# 自動產生摘要
if not article.summary:
article.summary = article.content[:200]
if commit:
article.save()
self.save_m2m()
return article
在 View 中使用時,可以透過自訂參數傳入額外資料:
# views.py
if form.is_valid():
article = form.save(author=request.user)
Formset 表單集合:批次管理多筆資料
Formset(表單集合)是 Django 用來在同一頁面中管理多個相同表單的機制。當你需要讓使用者一次填寫或編輯多筆資料時,Formset 是最佳解決方案。
formset_factory():基礎 Formset
formset_factory() 可以將一般的 Form 類別轉換為 Formset:
# forms.py
from django import forms
class ItemForm(forms.Form):
name = forms.CharField(max_length=100, label='品名')
quantity = forms.IntegerField(min_value=1, label='數量')
price = forms.DecimalField(max_digits=10, decimal_places=2, label='單價')
# views.py
from django.forms import formset_factory
from .forms import ItemForm
ItemFormSet = formset_factory(
ItemForm,
extra=3, # 預設顯示 3 個空白表單
max_num=10, # 最多允許 10 個表單
can_delete=True, # 允許標記刪除
)
def add_items(request):
if request.method == 'POST':
formset = ItemFormSet(request.POST)
if formset.is_valid():
for form in formset:
if form.cleaned_data and not form.cleaned_data.get('DELETE'):
# 處理每一筆有效資料
name = form.cleaned_data['name']
quantity = form.cleaned_data['quantity']
# ... 進行儲存或其他操作
return redirect('item_list')
else:
formset = ItemFormSet()
return render(request, 'items/add.html', {'formset': formset})
modelformset_factory():與 Model 結合
如果你需要直接對資料庫中的多筆記錄進行批次編輯,modelformset_factory() 會更加方便:
# views.py
from django.forms import modelformset_factory
from .models import Item
ItemModelFormSet = modelformset_factory(
Item,
fields=['name', 'quantity', 'price'],
extra=2, # 額外空白表單數量
can_delete=True,
)
def manage_items(request):
if request.method == 'POST':
formset = ItemModelFormSet(request.POST)
if formset.is_valid():
formset.save() # 一次儲存所有變更(新增、修改、刪除)
return redirect('item_list')
else:
formset = ItemModelFormSet(queryset=Item.objects.filter(is_active=True))
return render(request, 'items/manage.html', {'formset': formset})
模板中渲染 Formset
Formset 在模板中渲染時,必須包含 management_form,它負責追蹤表單的總數、初始數量等元資料:
<!-- templates/items/manage.html -->
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
<table>
<thead>
<tr>
<th>品名</th>
<th>數量</th>
<th>單價</th>
<th>刪除</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>{{ form.name }}</td>
<td>{{ form.quantity }}</td>
<td>{{ form.price }}</td>
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="submit">儲存</button>
</form>
如果忘記在模板中加入 {{ formset.management_form }},Django 會拋出 ManagementForm data is missing or has been tampered with 錯誤。
檔案上傳處理
檔案上傳是 Web 應用程式的常見需求。Django 透過 FileField 與 ImageField 提供了完整的檔案處理機制。
MEDIA_URL 與 MEDIA_ROOT 設定
在處理檔案上傳之前,需要先在 settings.py 中設定媒體檔案(Media Files)的儲存路徑與 URL:
# settings.py
import os
# 上傳檔案在磁碟上的根目錄
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# 存取上傳檔案時的 URL 前綴
MEDIA_URL = '/media/'
在開發環境中,還需要在 urls.py 中加入媒體檔案的路由,讓 Django 開發伺服器能提供這些檔案:
# urls.py
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# ... 你的 URL 路由
]
# 僅在開發環境中加入
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
FileField 與 ImageField
在 Model 中使用 FileField 與 ImageField 來定義檔案欄位。ImageField 繼承自 FileField,額外驗證上傳的檔案是否為有效的圖片格式(需要安裝 Pillow 套件):
# models.py
from django.db import models
class Document(models.Model):
title = models.CharField(max_length=200, verbose_name='標題')
file = models.FileField(upload_to='documents/%Y/%m/', verbose_name='檔案')
image = models.ImageField(upload_to='images/', blank=True, verbose_name='預覽圖')
uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name='上傳時間')
def __str__(self):
return self.title
upload_to 參數指定檔案在 MEDIA_ROOT 下的子目錄。支援 strftime 格式化語法,例如 %Y/%m/ 會自動依年份和月份分類存放。
表單驗證與 View 處理
檔案上傳的表單需要特別注意兩件事:模板中的 enctype 屬性,以及 View 中的 request.FILES 參數。
# forms.py
import os
from django import forms
from .models import Document
class DocumentUploadForm(forms.ModelForm):
class Meta:
model = Document
fields = ['title', 'file', 'image']
def clean_file(self):
"""自訂檔案驗證:限制大小與格式"""
file = self.cleaned_data.get('file')
if file:
# 檔案大小限制(10MB)
if file.size > 10 * 1024 * 1024:
raise forms.ValidationError('檔案大小不得超過 10MB。')
# 副檔名限制
allowed_extensions = ['.pdf', '.docx', '.xlsx']
ext = os.path.splitext(file.name)[1].lower()
if ext not in allowed_extensions:
raise forms.ValidationError(
f'只允許上傳 {", ".join(allowed_extensions)} 格式。'
)
return file
# views.py
def upload_document(request):
if request.method == 'POST':
# 注意:必須同時傳入 request.POST 和 request.FILES
form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return redirect('document_list')
else:
form = DocumentUploadForm()
return render(request, 'documents/upload.html', {'form': form})
模板中的 <form> 標籤必須設定 enctype="multipart/form-data",否則瀏覽器不會傳送檔案資料:
<!-- templates/documents/upload.html -->
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">上傳</button>
</form>
Widget 自訂
Widget(控件)決定了表單欄位在 HTML 中的呈現方式。透過自訂 Widget,你可以為表單欄位套用 CSS class、設定 HTML 屬性,讓表單與前端框架(如 Bootstrap、Tailwind CSS)無縫整合。
# forms.py
from django import forms
class StyledArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content', 'is_published']
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '請輸入文章標題',
}),
'content': forms.Textarea(attrs={
'class': 'form-control',
'rows': 10,
'placeholder': '請輸入文章內容...',
}),
'is_published': forms.CheckboxInput(attrs={
'class': 'form-check-input',
}),
}
除了在 Meta.widgets 中設定之外,也可以在 __init__ 方法中動態修改 Widget 屬性:
class StyledArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content', 'is_published']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 為所有欄位統一加上 CSS class
for field_name, field in self.fields.items():
if isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs['class'] = 'form-check-input'
else:
field.widget.attrs['class'] = 'form-control'
以下是常用 Widget 的對照表:
| Widget | 對應 HTML | 適用欄位 |
|---|---|---|
TextInput | <input type="text"> | CharField |
Textarea | <textarea> | CharField、TextField |
NumberInput | <input type="number"> | IntegerField、DecimalField |
EmailInput | <input type="email"> | EmailField |
PasswordInput | <input type="password"> | CharField |
DateInput | <input type="date"> | DateField |
Select | <select> | ChoiceField |
CheckboxInput | <input type="checkbox"> | BooleanField |
CheckboxSelectMultiple | 多個 <input type="checkbox"> | MultipleChoiceField |
RadioSelect | 多個 <input type="radio"> | ChoiceField |
FileInput | <input type="file"> | FileField |
表單與 CBV 整合
Django 的 CBV(Class-Based Views,類別基礎視圖)提供了多個內建的表單處理 View,大幅減少重複的樣板程式碼(Boilerplate Code)。
FormView:通用表單處理
FormView 適合處理不對應 Model 的一般表單,例如聯絡表單或搜尋表單:
# views.py
from django.views.generic.edit import FormView
from django.urls import reverse_lazy
from .forms import ContactForm
class ContactView(FormView):
template_name = 'contact.html'
form_class = ContactForm
success_url = reverse_lazy('contact_success')
def form_valid(self, form):
"""表單驗證通過後執行的邏輯"""
# 取得清理後的資料
name = form.cleaned_data['name']
email = form.cleaned_data['email']
message = form.cleaned_data['message']
# 發送郵件或其他處理
send_contact_email(name, email, message)
return super().form_valid(form)
CreateView 與 UpdateView
CreateView 與 UpdateView 是專為 ModelForm 設計的 CBV,分別處理新增與編輯的場景:
# views.py
from django.views.generic.edit import CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import Article
from .forms import ArticleForm
class ArticleCreateView(LoginRequiredMixin, CreateView):
model = Article
form_class = ArticleForm
template_name = 'articles/form.html'
success_url = reverse_lazy('article_list')
def form_valid(self, form):
"""在儲存前自動填入作者"""
form.instance.author = self.request.user
return super().form_valid(form)
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
model = Article
form_class = ArticleForm
template_name = 'articles/form.html'
success_url = reverse_lazy('article_list')
由於 CreateView 和 UpdateView 使用相同的 form_class,模板也可以共用同一個 form.html,在模板中透過 object 變數是否存在來判斷是新增還是編輯:
<!-- templates/articles/form.html -->
<h2>{% if object %}編輯文章{% else %}建立文章{% endif %}</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">儲存</button>
</form>
get_form_kwargs():傳遞額外參數給表單
當表單的 __init__ 方法需要額外參數時(例如當前登入的使用者),可以覆寫 get_form_kwargs() 來傳遞:
# forms.py
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content', 'category']
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
if user and not user.is_staff:
# 非管理員只能選擇已發布的類別
self.fields['category'].queryset = Category.objects.filter(is_public=True)
# views.py
class ArticleCreateView(LoginRequiredMixin, CreateView):
model = Article
form_class = ArticleForm
template_name = 'articles/form.html'
def get_form_kwargs(self):
"""將當前使用者傳遞給表單"""
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
form.instance.author = self.request.user
return super().form_valid(form)
Form Mixin 模式
當多個 View 需要共用相同的表單處理邏輯時,Mixin(混入)模式可以避免程式碼重複。Mixin 是一個不單獨使用的類別,它提供特定的方法或屬性,透過多重繼承(Multiple Inheritance)注入到目標類別中。
# mixins.py
class FormStyleMixin:
"""為所有表單欄位自動加上 Bootstrap CSS class"""
def get_form(self, form_class=None):
form = super().get_form(form_class)
for field_name, field in form.fields.items():
if isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs.setdefault('class', 'form-check-input')
else:
field.widget.attrs.setdefault('class', 'form-control')
return form
# mixins.py
class AutoAuthorMixin:
"""自動將當前使用者設為 author 欄位"""
def form_valid(self, form):
if hasattr(form, 'instance') and hasattr(form.instance, 'author'):
form.instance.author = self.request.user
return super().form_valid(form)
將多個 Mixin 組合在一起使用:
# views.py
from django.views.generic.edit import CreateView, UpdateView
from .mixins import FormStyleMixin, AutoAuthorMixin
class ArticleCreateView(LoginRequiredMixin, FormStyleMixin, AutoAuthorMixin, CreateView):
model = Article
form_class = ArticleForm
template_name = 'articles/form.html'
success_url = reverse_lazy('article_list')
class ArticleUpdateView(LoginRequiredMixin, FormStyleMixin, UpdateView):
model = Article
form_class = ArticleForm
template_name = 'articles/form.html'
success_url = reverse_lazy('article_list')
使用 Mixin 時需要注意 MRO(Method Resolution Order,方法解析順序):Python 會從左到右搜尋父類別的方法。因此,具有更高優先級的 Mixin 應該放在繼承列表的左邊,而基礎 View 類別(如 CreateView)放在最右邊。
總結
本篇深入探討了 Django Forms 的進階主題。首先介紹了 ModelForm 如何透過 Meta 類別自動從 Model 產生表單欄位,以及 save() 方法的 commit 參數與覆寫技巧。接著學習了 Formset 機制,包含 formset_factory() 與 modelformset_factory() 兩種工廠函式,讓你能在同一頁面批次處理多筆資料。在 檔案上傳 方面,掌握了 MEDIA_ROOT / MEDIA_URL 設定、FileField 與 ImageField 的使用方式,以及 enctype="multipart/form-data" 和 request.FILES 的必要性。此外,透過 Widget 自訂實現了表單與前端框架的整合。最後介紹了表單與 CBV 的搭配方式,包含 FormView、CreateView、UpdateView 與 get_form_kwargs() 的運用,以及 Form Mixin 模式如何將共用邏輯抽取為可重複使用的元件。掌握這些進階技巧後,你將能夠應對絕大多數的表單開發需求,寫出更簡潔、更易維護的 Django 程式碼。