Django基礎 #6 Forms と ModelForm
#4 URL と Views の最後で request.POST.get("title", "").strip() のような手作業でフォームデータを受け取りました。いつもこうしていると、検証 / エラーメッセージ / 再レンダー / CSRF を毎回書き直すことになります。Django Form がこれを一度に整理します。
Form の正体 — 何をしてくれるのか #
Django Form は 5 つの仕事を一カ所でしてくれます。
- HTML フォームのレンダリング —
<input>、<select>、<textarea>を自動生成 - データの検証 — タイプ、長さ、正規表現、ユーザー定義のルール
- エラーメッセージ — 多言語対応、フィールド別表示
- タイプ変換 — 文字列入力 → Python 型 (
int、date、datetime) - 再レンダー時の入力保持 — 検証失敗時にユーザーが入力した値そのまま
Form クラス一発 #
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=50, label="名前")
email = forms.EmailField(label="メール")
subject = forms.CharField(max_length=200, label="件名")
message = forms.CharField(widget=forms.Textarea, label="内容")
subscribe = forms.BooleanField(required=False, label="ニュースレター購読")このクラス 1 つで 5 つのフィールドの HTML、検証、エラーをすべて作ります。
View での使用 — 標準パターン #
from django.shortcuts import redirect, render
from .forms import ContactForm
def contact(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
# form.cleaned_data が検証 / 変換されたデータ
name = form.cleaned_data["name"]
email = form.cleaned_data["email"]
# ... 処理 (メール送信など)
return redirect("blog:contact_thanks")
else:
form = ContactForm() # GET — 空のフォーム
return render(request, "blog/contact.html", {"form": form})これが Django Form の標準フロー です。毎回この形です。
- GET — 空の form インスタンス
- POST —
request.POSTで form を作る is_valid()— 検証実行- 成功 —
cleaned_dataを使う + redirect - 失敗 — 同じフォームを再レンダー (エラーメッセージが自動で入る)
テンプレートでの Form レンダリング #
もっとも短い方法:
{% extends "base.html" %}
{% block content %}
<h1>お問い合わせ</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">送信</button>
</form>
{% endblock %}{{ form.as_p }} — 各フィールドを <p> で包み、ラベル + input + エラーを一度に。素早いプロトタイプに良いです。
他のオプション:
{{ form.as_table }}—<table>の行で{{ form.as_ul }}—<ul>で{{ form.as_div }}—<div>で (Django 4.1+)
フィールド別の細かいレンダリング #
実際のサイトではデザインが入るのでフィールド別に直接描画します。
<form method="post" class="space-y-4">
{% csrf_token %}
<div>
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
{% if form.name.errors %}
<ul class="errors">
{% for error in form.name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div>
<label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{# 非フィールドエラー (form-wide) #}
{% if form.non_field_errors %}
<div class="errors">{{ form.non_field_errors }}</div>
{% endif %}
<button type="submit">送信</button>
</form>{{ form.name }} だけ出力すると widget がそのまま描画されます (<input type="text" name="name" id="id_name" maxlength="50">)。ラベル、エラーは別途。
Widget の変更 — input タイプを変える #
class ContactForm(forms.Form):
name = forms.CharField(
max_length=50,
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "名前"}),
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={"class": "form-control"}),
)
message = forms.CharField(
widget=forms.Textarea(attrs={"class": "form-control", "rows": 5}),
)
birthday = forms.DateField(
widget=forms.DateInput(attrs={"type": "date"}),
)
color = forms.ChoiceField(
choices=[("red", "赤"), ("blue", "青")],
widget=forms.RadioSelect,
)attrs で HTML 属性 (class、placeholder、data-…) を追加。CSS フレームワーク (Bootstrap、Tailwind) とよく合います。
ユーザー定義の検証 — clean_<field> / clean
#
1 フィールドの検証 #
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
password = forms.CharField(widget=forms.PasswordInput, min_length=8)
password_confirm = forms.CharField(widget=forms.PasswordInput)
def clean_username(self):
username = self.cleaned_data["username"]
from django.contrib.auth import get_user_model
if get_user_model().objects.filter(username=username).exists():
raise forms.ValidationError("すでに使用されている ID です。")
return usernameclean_<フィールド名> メソッドはそのフィールドの検証が終わった後に呼ばれます。必ず 検証された値を返さなければ なりません。
複数のフィールドを一緒に検証 #
def clean(self):
cleaned = super().clean()
pw1 = cleaned.get("password")
pw2 = cleaned.get("password_confirm")
if pw1 and pw2 and pw1 != pw2:
raise forms.ValidationError("パスワードが一致しません。")
return cleanedclean() はすべてのフィールド別検証の後。2 つのフィールドの比較のようなフォーム単位のルールはここで。
特定のフィールドにエラーをぶら下げたい場合:
def clean(self):
cleaned = super().clean()
if cleaned.get("password") != cleaned.get("password_confirm"):
self.add_error("password_confirm", "パスワードが一致しません。")
return cleanedModelForm — モデルからフォームを自動生成 #
同じフィールド定義をモデルとフォームの両方に書くのは非効率です。ModelForm はモデルのフィールドをそのままフォームにしてくれます。
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "content", "tags", "is_published"]
widgets = {
"content": forms.Textarea(attrs={"rows": 10}),
}
labels = {
"title": "タイトル",
"content": "本文",
"tags": "タグ",
"is_published": "公開",
}3 行 (model、fields) で終わり。フィールドの種類、検証、label、max_length すべてモデルから自動的に引き継がれます。
fields vs exclude
#
fields = ["title", "content"]— 明示したものだけ フォームにfields = "__all__"— すべてのフィールド (危険、推奨しない)exclude = ["author", "created_at"]— 明示したものだけ除外
明示的なホワイトリスト (fields = [...]) が安全です — 新しいフィールドをモデルに追加したとき、意図せずフォームに公開されるのを防げます。
View での ModelForm — save()
#
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from .forms import PostForm
from .models import Post
@login_required
def post_new(request):
if request.method == "POST":
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False) # まだ DB に保存しない
post.author = request.user # ユーザーを埋め込んで
post.save() # その後保存
form.save_m2m() # M2M フィールド (tags) を別途保存
return redirect("blog:post_detail", post_id=post.id)
else:
form = PostForm()
return render(request, "blog/post_form.html", {"form": form})
@login_required
def post_edit(request, post_id):
post = get_object_or_404(Post, pk=post_id, author=request.user)
if request.method == "POST":
form = PostForm(request.POST, instance=post) # instance= が核心
if form.is_valid():
form.save()
return redirect("blog:post_detail", post_id=post.id)
else:
form = PostForm(instance=post)
return render(request, "blog/post_form.html", {"form": form})主要パターン:
form.save()— DB に保存しインスタンスを返すform.save(commit=False)— インスタンスだけ作って保存は遅らせる。author のようにフォームにないフィールド を埋め込むときform.save_m2m()—commit=Falseの後に M2M フィールドを別途保存 (M2M はインスタンスに PK がないと保存できない)PostForm(request.POST, instance=post)— 修正時に既存のインスタンスを束ねてフォームを生成
このパターン 1 つだけ覚えれば、ほとんどの CRUD フォームができます。
ファイルアップロード #
画像 / 添付ファイルを受け取るフォームには 2 つ追加が必要です。
1. モデルに FileField / ImageField
#
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
cover = models.ImageField(upload_to="covers/%Y/%m/", blank=True, null=True)
attachment = models.FileField(upload_to="attachments/", blank=True, null=True)upload_to — MEDIA_ROOT 内の保存パス。%Y/%m/ のようなプレースホルダも可能 (covers/2026/04/... のように)。
ImageField は Pillow パッケージが必要です — uv add Pillow。
2. View で request.FILES
#
@login_required
def post_new(request):
if request.method == "POST":
form = PostForm(request.POST, request.FILES) # request.FILES を追加
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m()
return redirect("blog:post_detail", post_id=post.id)
else:
form = PostForm()
return render(request, "blog/post_form.html", {"form": form})PostForm(request.POST, request.FILES) のように 第二引数に request.FILES を一緒に。
3. テンプレートに enctype
#
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button>アップロード</button>
</form>enctype="multipart/form-data" が抜けるとファイルが届きません — よく忘れます。
CSRF — もう一度 #
Django のすべての POST リクエストは CSRF トークンを要求します。抜けると 403 Forbidden。
<form method="post">
{{ form.as_p }}
<button>保存</button>
</form><form method="post">
{% csrf_token %}
{{ form.as_p }}
<button>保存</button>
</form>JavaScript fetch / AJAX でもヘッダにトークンを一緒に送る必要があります — X-CSRFToken: <token>。Django 公式ドキュメントの Using CSRF protection with AJAX を参照。
Formset — 同じフォームを複数 #
1 ページで同じフォームを N 個処理する必要があれば formset。例: 1 つの記事に 5 つのコメントを一度に保存。
from django.forms import modelformset_factory
CommentFormSet = modelformset_factory(Comment, fields=["body"], extra=3)
# view
formset = CommentFormSet(request.POST or None, queryset=Comment.objects.none())
if request.method == "POST" and formset.is_valid():
formset.save()基礎では深く入りませんが、「1 ページに N 個のフォーム」が浮かんだとき formset という名前を覚えておいてください。
Form vs ModelForm — いつ何を
#
| 状況 | 使うもの |
|---|---|
| DB モデルと 1:1 マッピング (記事作成 / 修正) | ModelForm |
| モデルと無関係な入力 (検索、お問い合わせ、ログイン) | Form |
| モデル + 追加フィールド | ModelForm にフィールド追加、または別途 Form |
最初は ModelForm から 始めてください。そこから足りないときに通常の Form に下りていくのが自然です。
よく使うパターン — 一カ所に #
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from .forms import PostForm
from .models import Post
@login_required
def post_new(request):
form = PostForm(request.POST or None, request.FILES or None)
if request.method == "POST" and form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m()
return redirect("blog:post_detail", post_id=post.id)
return render(request, "blog/post_form.html", {"form": form})
@login_required
def post_edit(request, post_id):
post = get_object_or_404(Post, pk=post_id, author=request.user)
form = PostForm(request.POST or None, request.FILES or None, instance=post)
if request.method == "POST" and form.is_valid():
form.save()
return redirect("blog:post_detail", post_id=post.id)
return render(request, "blog/post_form.html", {"form": form, "post": post})request.POST or None トリック — POST ならデータ、GET なら None (空のフォーム)。if/else 分岐を減らすよくあるコンベンションです。
まとめ #
今回つかんだもの:
- Form がしてくれる仕事 — レンダー、検証、エラー、タイプ変換、入力保持
- 標準フロー — GET (空のフォーム) / POST (
is_valid→cleaned_data→ redirect) - テンプレートのレンダー —
{{ form.as_p }}またはフィールド別に直接 widget、attrsで HTML 属性 / CSS クラスclean_<フィールド>とclean()でユーザー定義の検証forms.ValidationErrorでエラーを投げる- ModelForm — モデルからフォーム自動生成、
Meta.model + fields save(commit=False)+ 追加フィールドを埋める +save()+save_m2m()instance=postで修正フォーム- ファイルアップロード —
request.FILES+enctype="multipart/form-data" - CSRF トークンはすべての POST に必須
request.POST or Noneトリック
次回(#7 Django Admin とビルトイン認証)では Admin の自動 CRUD 画面と Django のビルトイン認証 (User、login_required) を扱います。#1 でした約束 — 「Admin が無料で付いてくる」 — をそこで確認します。