Django Forms 進階:ModelForm、Formset 與檔案上傳 | Django 教學

2026/06/07 2026/05/24
Django Forms 進階:ModelForm、Formset 與檔案上傳 | Django 教學

在前一篇教學中,我們學會了 Django Forms 的基礎用法,包含欄位定義、驗證流程與模板渲染。然而在實務開發中,表單往往需要與 Model 緊密結合、一次處理多筆資料,或是支援 檔案上傳 等進階需求。本篇將帶你深入 ModelForm 的自動表單產生機制、Formset 的批次資料管理、FileFieldImageField 的檔案處理,並介紹 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 透過 FileFieldImageField 提供了完整的檔案處理機制。

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 中使用 FileFieldImageField 來定義檔案欄位。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 屬性,讓表單與前端框架(如 BootstrapTailwind 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

CreateViewUpdateView 是專為 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')

由於 CreateViewUpdateView 使用相同的 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 設定、FileFieldImageField 的使用方式,以及 enctype="multipart/form-data"request.FILES 的必要性。此外,透過 Widget 自訂實現了表單與前端框架的整合。最後介紹了表單與 CBV 的搭配方式,包含 FormViewCreateViewUpdateViewget_form_kwargs() 的運用,以及 Form Mixin 模式如何將共用邏輯抽取為可重複使用的元件。掌握這些進階技巧後,你將能夠應對絕大多數的表單開發需求,寫出更簡潔、更易維護的 Django 程式碼。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。