Djangoフォーム入門
Form・ModelForm・バリデーションの基本
Djangoのフォームシステムを基礎から解説。Form、ModelForm、バリデーション、ウィジェットカスタマイズ、FormSetまで学べます。
こんな人向けの記事です
- Djangoフォームの作り方を学びたい
- ModelFormでモデルと連携したフォームを作りたい
- バリデーションの実装方法を理解したい
Step 1Djangoフォームの基本(forms.Form)
Djangoのフォームクラスを使えば、HTMLフォームの生成・データ検証・エラー表示を一元管理できます。生のHTMLで<input>タグを書く方法と比べて、セキュリティ(CSRF対策)やバリデーションが自動で組み込まれる点が大きなメリットです。
基本的なフォームクラスの定義
forms.py
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(
label='お名前',
max_length=100,
widget=forms.TextInput(attrs={'placeholder': '山田太郎'})
)
email = forms.EmailField(
label='メールアドレス',
widget=forms.EmailInput(attrs={'placeholder': 'example@mail.com'})
)
message = forms.CharField(
label='お問い合わせ内容',
widget=forms.Textarea(attrs={'rows': 5})
)
ビューでフォームを使う
views.py
from django.shortcuts import render, redirect
from .forms import ContactForm
def contact_view(request):
if request.method == 'POST':
form = ContactForm(request.POST)
if form.is_valid():
# バリデーション済みデータを取得
name = form.cleaned_data['name']
email = form.cleaned_data['email']
message = form.cleaned_data['message']
# メール送信などの処理
return redirect('contact_done')
else:
form = ContactForm()
return render(request, 'contact.html', {'form': form})
テンプレートでフォームを表示
templates/contact.html
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">送信</button>
</form>
フォームの表示メソッド
form.as_p は各フィールドを<p>タグで囲んで出力します。他にも form.as_div(div タグ)、form.as_table(table タグ)、form.as_ul(ul タグ)が使えます。Django 5.0 以降では as_div がデフォルト推奨です。
よく使うフィールドタイプ
| フィールド | 用途 | HTMLの出力 |
|---|---|---|
CharField | テキスト入力 | <input type="text"> |
EmailField | メールアドレス | <input type="email"> |
IntegerField | 整数 | <input type="number"> |
BooleanField | チェックボックス | <input type="checkbox"> |
ChoiceField | セレクトボックス | <select> |
DateField | 日付入力 | <input type="text"> |
FileField | ファイルアップロード | <input type="file"> |
Step 2ModelForm(モデルと連携したフォーム)
ModelFormを使えば、モデルの定義から自動的にフォームを生成できます。CRUD操作の実装が大幅に簡単になります。
モデルの定義
models.py
from django.db import models
class BlogPost(models.Model):
title = models.CharField('タイトル', max_length=200)
content = models.TextField('本文')
category = models.CharField('カテゴリ', max_length=50, choices=[
('tech', '技術'),
('life', '生活'),
('other', 'その他'),
])
is_published = models.BooleanField('公開', default=False)
created_at = models.DateTimeField('作成日時', auto_now_add=True)
def __str__(self):
return self.title
ModelFormの定義
forms.py
from django import forms
from .models import BlogPost
class BlogPostForm(forms.ModelForm):
class Meta:
model = BlogPost
fields = ['title', 'content', 'category', 'is_published']
# fields = '__all__' # 全フィールドを含める場合
# exclude = ['created_at'] # 特定フィールドを除外する場合
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'タイトルを入力'
}),
'content': forms.Textarea(attrs={
'class': 'form-control',
'rows': 10
}),
}
labels = {
'title': '記事タイトル',
'content': '記事本文',
}
help_texts = {
'title': '200文字以内で入力してください。',
}
ModelFormを使ったCRUDビュー
views.py
from django.shortcuts import render, redirect, get_object_or_404
from .forms import BlogPostForm
from .models import BlogPost
# 新規作成
def post_create(request):
if request.method == 'POST':
form = BlogPostForm(request.POST)
if form.is_valid():
post = form.save() # モデルに保存
return redirect('post_detail', pk=post.pk)
else:
form = BlogPostForm()
return render(request, 'post_form.html', {'form': form})
# 編集
def post_update(request, pk):
post = get_object_or_404(BlogPost, pk=pk)
if request.method == 'POST':
form = BlogPostForm(request.POST, instance=post)
if form.is_valid():
form.save() # 既存レコードを更新
return redirect('post_detail', pk=post.pk)
else:
form = BlogPostForm(instance=post) # 既存データを初期値に
return render(request, 'post_form.html', {'form': form})
fieldsの指定は必須
ModelFormではfieldsまたはexcludeを必ず指定してください。指定しないとエラーになります。セキュリティの観点から、fields = '__all__'よりも必要なフィールドを明示的に列挙する方が安全です。
save(commit=False)の使い方
form.save(commit=False)を使うと、データベースに保存せずにモデルインスタンスを取得できます。保存前にフィールドを追加・変更したい場合に便利です。post = form.save(commit=False)post.author = request.user # ログインユーザーを設定post.save()
Step 3フォームのバリデーション(cleanメソッド)
Djangoフォームには複数段階のバリデーションがあります。フィールド単位のバリデーションから、複数フィールドをまたいだ検証まで柔軟に実装できます。
バリデーションの流れ
| 順序 | メソッド | 対象 | 用途 |
|---|---|---|---|
| 1 | to_python() | 各フィールド | 値をPythonオブジェクトに変換 |
| 2 | validate() | 各フィールド | フィールド固有のバリデーション |
| 3 | run_validators() | 各フィールド | バリデータの実行 |
| 4 | clean_<fieldname>() | 各フィールド | フィールド単位のカスタムバリデーション |
| 5 | clean() | フォーム全体 | 複数フィールドにまたがるバリデーション |
フィールド単位のバリデーション(clean_<fieldname>)
forms.py
from django import forms
from django.core.exceptions import ValidationError
class RegistrationForm(forms.Form):
username = forms.CharField(max_length=30)
email = forms.EmailField()
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(
label='パスワード(確認)',
widget=forms.PasswordInput
)
def clean_username(self):
"""ユーザー名のカスタムバリデーション"""
username = self.cleaned_data['username']
if len(username) < 3:
raise ValidationError('ユーザー名は3文字以上にしてください。')
if not username.isalnum():
raise ValidationError('ユーザー名は英数字のみ使用できます。')
return username
def clean_email(self):
"""メールアドレスの重複チェック"""
email = self.cleaned_data['email']
from django.contrib.auth.models import User
if User.objects.filter(email=email).exists():
raise ValidationError('このメールアドレスは既に登録されています。')
return email
フォーム全体のバリデーション(clean)
forms.py(続き)
def clean(self):
"""複数フィールドにまたがるバリデーション"""
cleaned_data = super().clean()
password = cleaned_data.get('password')
password_confirm = cleaned_data.get('password_confirm')
if password and password_confirm:
if password != password_confirm:
raise ValidationError('パスワードが一致しません。')
return cleaned_data
バリデータを使う方法
validators.py
from django.core.validators import (
MinLengthValidator,
MaxLengthValidator,
RegexValidator,
)
from django import forms
class ProductForm(forms.Form):
name = forms.CharField(
validators=[MinLengthValidator(2, 'もっと長い名前にしてください。')]
)
code = forms.CharField(
validators=[
RegexValidator(
regex=r'^[A-Z]{2}-\d{4}$',
message='コードは「XX-0000」の形式で入力してください。'
)
]
)
price = forms.IntegerField(
min_value=0, # 0以上
max_value=1000000 # 100万以下
)
テンプレートでエラーを表示
templates/form.html
<form method="post">
{% csrf_token %}
{# フォーム全体のエラー #}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{# 各フィールドを個別に表示 #}
{% for field in form %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
<label>{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<ul class="error-list">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% if field.help_text %}
<small>{{ field.help_text }}</small>
{% endif %}
</div>
{% endfor %}
<button type="submit">登録</button>
</form>
Step 4フォームウィジェットのカスタマイズ
ウィジェットは、フォームフィールドのHTML表現を制御するクラスです。見た目やクラス名、属性を自在にカスタマイズできます。
主なウィジェット一覧
| ウィジェット | HTML出力 | 主な用途 |
|---|---|---|
TextInput | <input type="text"> | 短いテキスト |
Textarea | <textarea> | 長いテキスト |
PasswordInput | <input type="password"> | パスワード |
NumberInput | <input type="number"> | 数値入力 |
EmailInput | <input type="email"> | メールアドレス |
DateInput | <input type="date"> | 日付選択 |
Select | <select> | 単一選択 |
SelectMultiple | <select multiple> | 複数選択 |
CheckboxInput | <input type="checkbox"> | チェックボックス |
RadioSelect | <input type="radio"> | ラジオボタン |
FileInput | <input type="file"> | ファイル選択 |
HiddenInput | <input type="hidden"> | 非表示値 |
ウィジェットにCSSクラスやHTML属性を設定する
forms.py
class StyledForm(forms.Form):
# 定義時に指定
name = forms.CharField(
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '名前を入力',
'id': 'input-name',
'autofocus': True,
})
)
# ChoiceFieldをラジオボタンにする
gender = forms.ChoiceField(
choices=[('male', '男性'), ('female', '女性'), ('other', 'その他')],
widget=forms.RadioSelect(attrs={'class': 'radio-inline'})
)
# 日付入力にHTML5のdateピッカーを使う
birthday = forms.DateField(
widget=forms.DateInput(attrs={
'type': 'date',
'class': 'form-control',
})
)
__init__でウィジェットを動的にカスタマイズ
forms.py
class DynamicForm(forms.ModelForm):
class Meta:
model = BlogPost
fields = ['title', 'content', 'category']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 全フィールドにBootstrapのクラスを一括適用
for field_name, field in self.fields.items():
field.widget.attrs['class'] = 'form-control'
field.widget.attrs['autocomplete'] = 'off'
# 特定フィールドだけ変更
self.fields['title'].widget.attrs['placeholder'] = 'タイトルを入力'
self.fields['content'].widget.attrs['rows'] = 8
django-widget-tweaks
テンプレート側でウィジェットの属性を操作したい場合、django-widget-tweaksパッケージが便利です。{{ form.name|add_class:"form-control" }} のように記述できます。
Step 5フォームセット(FormSet)
FormSetは、同じフォームを複数同時に表示・処理するための仕組みです。一括登録や一括編集の画面を作るときに使います。
基本的なFormSetの使い方
forms.py
from django import forms
from django.forms import formset_factory
class ItemForm(forms.Form):
name = forms.CharField(label='商品名', max_length=100)
price = forms.IntegerField(label='価格', min_value=0)
quantity = forms.IntegerField(label='数量', min_value=1)
# FormSetを作成(最大5件、追加フォーム3件)
ItemFormSet = formset_factory(
ItemForm,
extra=3, # 空のフォームを3つ表示
max_num=5, # 最大5件まで
min_num=1, # 最低1件は必須
validate_min=True,
validate_max=True,
)
ビューでFormSetを処理
views.py
def item_create(request):
if request.method == 'POST':
formset = ItemFormSet(request.POST)
if formset.is_valid():
for form in formset:
if form.cleaned_data: # 空のフォームをスキップ
name = form.cleaned_data['name']
price = form.cleaned_data['price']
quantity = form.cleaned_data['quantity']
# データベースに保存する処理
return redirect('item_list')
else:
formset = ItemFormSet()
return render(request, 'item_form.html', {'formset': formset})
テンプレートでFormSetを表示
templates/item_form.html
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<div class="formset-row">
<h4>商品 {{ forloop.counter }}</h4>
{{ form.as_p }}
</div>
{% endfor %}
<button type="submit">一括登録</button>
</form>
management_formを忘れない
FormSetを使うテンプレートでは、必ず{{ formset.management_form }}を記述してください。これがないとDjangoがフォームの数を正しく認識できず、ManagementForm data is missing or has been tampered withエラーが発生します。
ModelFormSetの使い方
forms.py
from django.forms import modelformset_factory
from .models import BlogPost
BlogPostFormSet = modelformset_factory(
BlogPost,
fields=['title', 'content', 'is_published'],
extra=2, # 新規追加用のフォーム数
can_delete=True # 削除チェックボックスを表示
)
views.py
def post_bulk_edit(request):
if request.method == 'POST':
formset = BlogPostFormSet(request.POST)
if formset.is_valid():
formset.save() # まとめて保存&削除
return redirect('post_list')
else:
formset = BlogPostFormSet(
queryset=BlogPost.objects.filter(is_published=False)
)
return render(request, 'post_bulk_edit.html', {'formset': formset})
InlineFormSet(親子関係のフォーム)
forms.py
from django.forms import inlineformset_factory
from .models import Order, OrderItem
OrderItemFormSet = inlineformset_factory(
Order, # 親モデル
OrderItem, # 子モデル
fields=['product', 'quantity', 'price'],
extra=3,
can_delete=True,
)
views.py
def order_edit(request, pk):
order = get_object_or_404(Order, pk=pk)
if request.method == 'POST':
formset = OrderItemFormSet(request.POST, instance=order)
if formset.is_valid():
formset.save()
return redirect('order_detail', pk=pk)
else:
formset = OrderItemFormSet(instance=order)
return render(request, 'order_edit.html', {
'order': order,
'formset': formset,
})
Step 6ファイルアップロード
Djangoでファイルアップロードを実装するには、フォーム・ビュー・テンプレート・設定のそれぞれで対応が必要です。
settings.pyの設定
settings.py
# メディアファイルの設定
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
モデルにファイルフィールドを定義
models.py
from django.db import models
class Document(models.Model):
title = models.CharField('タイトル', max_length=200)
file = models.FileField('ファイル', upload_to='documents/%Y/%m/')
uploaded_at = models.DateTimeField('アップロード日時', auto_now_add=True)
def __str__(self):
return self.title
class Profile(models.Model):
user = models.OneToOneField('auth.User', on_delete=models.CASCADE)
avatar = models.ImageField(
'アバター',
upload_to='avatars/',
blank=True,
null=True
)
# ImageFieldを使うにはPillowが必要
# pip install Pillow
アップロードフォーム
forms.py
from django import forms
from .models import Document
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['title', 'file']
def clean_file(self):
file = self.cleaned_data.get('file')
if file:
# ファイルサイズを制限(5MB)
if file.size > 5 * 1024 * 1024:
raise forms.ValidationError('ファイルサイズは5MB以下にしてください。')
# 許可する拡張子を制限
allowed_extensions = ['.pdf', '.docx', '.xlsx', '.csv']
import os
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 document_upload(request):
if request.method == 'POST':
form = DocumentForm(request.POST, request.FILES) # request.FILESが必要
if form.is_valid():
form.save()
return redirect('document_list')
else:
form = DocumentForm()
return render(request, 'document_upload.html', {'form': form})
request.FILESを忘れない
ファイルアップロードでは、フォームのインスタンス化時にrequest.FILESを第2引数として渡す必要があります。これを忘れると、ファイルデータがフォームに渡されません。
テンプレート(enctype属性が必須)
templates/document_upload.html
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">アップロード</button>
</form>
enctype="multipart/form-data"は必須
ファイルアップロードのフォームではenctype="multipart/form-data"を指定しないと、ファイルデータがサーバーに送信されません。通常のフォームのデフォルトはapplication/x-www-form-urlencodedです。
開発環境でメディアファイルを配信する
urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include
urlpatterns = [
# ... 他のURL
]
# 開発環境でメディアファイルを配信
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
本番環境でのファイル配信
本番環境では、NginxやApacheでメディアファイルを直接配信するか、Amazon S3などのオブジェクトストレージを利用します。django-storagesパッケージを使えばS3との連携が簡単に設定できます。
まとめDjangoフォームのポイント
この記事で学んだこと
forms.FormでHTMLフォームの生成・バリデーション・エラー表示を一元管理できるModelFormでモデルから自動的にフォームを生成し、CRUD操作を簡潔に書けるclean_<fieldname>でフィールド単位、clean()でフォーム全体のバリデーションを実装できる- ウィジェットの
attrsでCSSクラスやHTML属性を自在にカスタマイズできる FormSet/ModelFormSet/InlineFormSetで複数フォームを一括処理できる- ファイルアップロードでは
request.FILESとenctype="multipart/form-data"が必須