Django基礎 #6 Forms と ModelForm

読了 8分

#4 URL と Views の最後で request.POST.get("title", "").strip() のような手作業でフォームデータを受け取りました。いつもこうしていると、検証 / エラーメッセージ / 再レンダー / CSRF を毎回書き直すことになります。Django Form がこれを一度に整理します。

Form の正体 — 何をしてくれるのか #

Django Form は 5 つの仕事を一カ所でしてくれます。

  • HTML フォームのレンダリング<input><select><textarea> を自動生成
  • データの検証 — タイプ、長さ、正規表現、ユーザー定義のルール
  • エラーメッセージ — 多言語対応、フィールド別表示
  • タイプ変換 — 文字列入力 → Python 型 (intdatedatetime)
  • 再レンダー時の入力保持 — 検証失敗時にユーザーが入力した値そのまま

Form クラス一発 #

blog/forms.py
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 での使用 — 標準パターン #

blog/views.py
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 の標準フロー です。毎回この形です。

  1. GET — 空の form インスタンス
  2. POSTrequest.POST で form を作る
  3. is_valid() — 検証実行
  4. 成功cleaned_data を使う + redirect
  5. 失敗 — 同じフォームを再レンダー (エラーメッセージが自動で入る)

テンプレートでの Form レンダリング #

もっとも短い方法:

blog/templates/blog/contact.html
{% 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 タイプを変える #

widget のカスタマイズ
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 フィールドの検証 #

フィールド別 clean
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 username

clean_<フィールド名> メソッドはそのフィールドの検証が終わった後に呼ばれます。必ず 検証された値を返さなければ なりません。

複数のフィールドを一緒に検証 #

model-level clean
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 cleaned

clean() はすべてのフィールド別検証の後。2 つのフィールドの比較のようなフォーム単位のルールはここで。

特定のフィールドにエラーをぶら下げたい場合:

add_error
def clean(self):
    cleaned = super().clean()
    if cleaned.get("password") != cleaned.get("password_confirm"):
        self.add_error("password_confirm", "パスワードが一致しません。")
    return cleaned

ModelForm — モデルからフォームを自動生成 #

同じフィールド定義をモデルとフォームの両方に書くのは非効率です。ModelForm はモデルのフィールドをそのままフォームにしてくれます。

blog/forms.py
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 行 (modelfields) で終わり。フィールドの種類、検証、label、max_length すべてモデルから自動的に引き継がれます。

fields vs exclude #

  • fields = ["title", "content"]明示したものだけ フォームに
  • fields = "__all__" — すべてのフィールド (危険、推奨しない)
  • exclude = ["author", "created_at"] — 明示したものだけ除外

明示的なホワイトリスト (fields = [...]) が安全です — 新しいフィールドをモデルに追加したとき、意図せずフォームに公開されるのを防げます。

View での ModelForm — save() #

blog/views.py
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 #

blog/models.py
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_toMEDIA_ROOT 内の保存パス。%Y/%m/ のようなプレースホルダも可能 (covers/2026/04/... のように)。

ImageFieldPillow パッケージが必要です — uv add Pillow

2. View で request.FILES #

ファイルアップロードの view
@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
<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

🚫 CSRF トークンの欠落
<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 つのコメントを一度に保存。

formset
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 に下りていくのが自然です。

よく使うパターン — 一カ所に #

blog/views.py — まとめ
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_validcleaned_data → redirect)
  • テンプレートのレンダー — {{ form.as_p }} またはフィールド別に直接
  • widgetattrs で 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 のビルトイン認証 (Userlogin_required) を扱います。#1 でした約束 — 「Admin が無料で付いてくる」 — をそこで確認します。

X